Skip to content

adapter: decompose ZSet storage into per-member wide-column keys#483

Open
bootjp wants to merge 13 commits intomainfrom
feat/reduce-lock
Open

adapter: decompose ZSet storage into per-member wide-column keys#483
bootjp wants to merge 13 commits intomainfrom
feat/reduce-lock

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 11, 2026

No description provided.

@bootjp bootjp force-pushed the feat/reduce-lock branch from 9c1b322 to 37d6099 Compare April 11, 2026 12:49
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions Redis Sorted Set (ZSet) storage from a single legacy blob to a wide-column format, where members and their scores are stored in individual keys alongside a score-based index. The changes include new helper functions for managing these keys, logic to migrate existing data upon access, and updates to ZSet commands to support incremental, diff-based updates. Feedback highlights an issue where empty ZSets might incorrectly persist their metadata key instead of being deleted, and suggests consistent use of the store.PrefixEnd utility to avoid naming inconsistencies.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 11, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates Redis Sorted Set (ZSet) storage from a legacy blob format to a wide-column layout using per-member keys and a score index. The changes include new key construction logic, sortable float encoding, and updated command implementations to support incremental updates and on-the-fly migration. Feedback focuses on optimizing storage operations by utilizing the new score index for ordered access and using prefix deletions to avoid inefficient full-set scans and potential memory exhaustion.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 11, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a wide-column storage format for Redis Sorted Sets, replacing the legacy blob-based storage with a more granular structure of metadata, member, and score-index keys. The changes include new key encoding logic, updated transaction state management for diff-based updates, and modified Redis command handlers to support the new format and facilitate on-the-fly migration. The review feedback highlights several areas for improvement, including optimizing bulk deletions via prefix-based operations, preventing potential OOM issues by bounding member scans, and refining migration logic to handle empty sets more efficiently.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 11, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a wide-column storage format for Redis Sorted Sets, transitioning from a single-blob model to a structure using metadata, member keys, and a score index. The changes include migration support, updated transaction state, and revised command implementations. Feedback focuses on critical atomicity risks in prefix deletions, potential memory exhaustion during unbounded scans, and performance bottlenecks in the O(N) diffing and range-scanning logic.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a wide-column storage format for Redis Sorted Sets (ZSets), utilizing metadata, member, and score-index keys to replace the legacy single-blob format. The implementation includes migration logic and updates to ZSet commands to support the new structure. Feedback identifies a bug where new prefixes were not registered for KEYS and SCAN commands. Additionally, reviewers raised concerns about potential out-of-memory (OOM) vulnerabilities and Raft limit issues due to operations that load entire sets into memory or perform unbounded scans during deletions and updates.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a wide-column storage format for Redis Sorted Sets (ZSets), moving away from the legacy single-blob approach to a per-member key structure with a dedicated score index. The changes include new key construction logic in store/zset_helpers.go, migration logic to handle legacy data, and updates to various Redis commands like ZADD, ZREM, and ZRANGE to support the new format. Feedback focuses on ensuring consistent score encoding for zero values and addressing potential performance and memory issues when deleting large sets by avoiding unbounded scans and excessive mutation batches.

Comment on lines +140 to +151
func EncodeSortableFloat64(dst []byte, f float64) {
if len(dst) < zsetScoreBinarySize {
return
}
bits := math.Float64bits(f)
if bits&(1<<float64SignBitPos) != 0 { // negative
bits = ^bits
} else {
bits ^= 1 << float64SignBitPos
}
binary.BigEndian.PutUint64(dst, bits)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current float64 encoding does not normalize -0.0 to +0.0. In IEEE 754, these have different bit patterns (0x8000000000000000 vs 0x0000000000000000), which results in different sortable byte sequences (0x7FFFFFFFFFFFFFFF vs 0x8000000000000000). Since Redis treats -0.0 and +0.0 as equal scores, they should have the same representation in the score index to ensure consistent range scans and ordering.

func EncodeSortableFloat64(dst []byte, f float64) {
	if len(dst) < zsetScoreBinarySize {
		return
	}
	if f == 0 {
		f = 0.0 // normalize -0.0 to +0.0
	}
	bits := math.Float64bits(f)
	if bits&(1<<float64SignBitPos) != 0 { // negative
		bits = ^bits
	} else {
		bits ^= 1 << float64SignBitPos
	}
	binary.BigEndian.PutUint64(dst, bits)
}

store.ZSetMemberScanPrefix(key),
store.ZSetScoreScanPrefix(key),
} {
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), math.MaxInt, readTS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using math.MaxInt as a limit for ScanAt when collecting keys for deletion can lead to unbounded memory growth and potential OOM issues if a ZSet contains a very large number of members. This violates the general rule to apply fixed bounds to collections that can grow from external requests. Consider using kv.DelPrefix for the member and score prefixes if the storage engine supports it, or implementing a chunked deletion approach to keep memory usage under control.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests.

Comment on lines +199 to +226
func (r *RedisServer) deleteZSetWideColumnElems(ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) {
_, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
if err != nil {
return nil, err
}
if !metaExists {
return nil, nil
}

elems := []*kv.Elem[kv.OP]{
{Op: kv.Del, Key: store.ZSetMetaKey(key)},
}

// Scan member keys and score-index keys for individual deletion.
for _, prefix := range [][]byte{
store.ZSetMemberScanPrefix(key),
store.ZSetScoreScanPrefix(key),
} {
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), math.MaxInt, readTS)
if err != nil {
return nil, errors.WithStack(err)
}
for _, kvp := range kvs {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}
}

return elems, nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The deleteZSetWideColumnElems function generates individual Del operations for every member and score index key. For large ZSets, this results in a massive number of operations being batched into a single Raft request, which can bloat the Raft log, increase apply latency, and potentially exceed message size limits. If kv.DelPrefix cannot be mixed with other mutations, consider if the DEL command logic can be refactored to use prefix deletion for ZSet internal data namespaces.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates Redis Sorted Set (ZSet) storage from a legacy single-blob format to a wide-column structure, utilizing separate keys for metadata, members, and score-based indexing. The changes include new helper functions for incremental updates, member scanning, and migration logic to ensure compatibility with existing data. Review feedback highlights a potential memory risk when scanning large sets with unbounded limits and suggests an optimization to avoid redundant metadata writes when the set's length remains unchanged.

Comment on lines +217 to +224
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), math.MaxInt, readTS)
if err != nil {
return nil, errors.WithStack(err)
}
for _, kvp := range kvs {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The use of math.MaxInt for the scan limit can lead to unbounded memory growth and potential OOM issues when deleting a very large ZSet. To prevent this, apply a fixed bound to collections that can grow from external requests. Consider implementing a batched scan-and-delete approach to process keys in smaller chunks, which would mitigate the memory pressure.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

Comment on lines +311 to +317
} else if len(elems) > 0 {
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The meta key is currently rewritten on every diff that modifies any element, even if the total number of members in the ZSet does not change (e.g., when only a score is updated). Since the meta key only stores the length, this write is redundant. Consider updating the logic to only write the meta key when the length actually changes.

Suggested change
} else if len(elems) > 0 {
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}
} else if len(origMembers) != len(newMembers) {
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a wide-column storage format for Redis Sorted Sets, transitioning from a single legacy blob to a more granular structure using metadata, member, and score-index keys. The changes include new key construction utilities, updated transaction states to support diff-based commits, and refactored Redis commands (e.g., ZADD, ZRANGE, BZPOPMIN) that handle both storage formats. Feedback was provided regarding the zrangeRead implementation, where a slice returned from the storage layer is reversed in-place; it is recommended to copy this slice first to prevent unintended side effects on shared data during the legacy migration phase.

Comment on lines +1551 to 1555
entries, err := r.loadZSetEntriesSorted(context.Background(), key, readTS)
if err != nil {
conn.WriteError(err.Error())
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The reverseZSetEntries function modifies its slice argument in-place. The entries slice returned from loadZSetEntriesSorted might be backed by a shared array, especially when falling back to the legacy blob format which involves unmarshaling. Modifying it directly could lead to unexpected side effects. It's safer to create a copy before performing the in-place reversal, as was done in the previous implementation.

 	entries, err := r.loadZSetEntriesSorted(context.Background(), key, readTS)
 	if err != nil {
 		conn.WriteError(err.Error())
 		return
 	}
 	// Make a copy before potential in-place reversal.
 	entries = append([]redisZSetEntry(nil), entries...)

bootjp added a commit that referenced this pull request Apr 12, 2026
…review

- Address PR #485 gemini review (4094524421):
  - Add stale claim key cleanup in PUSH write path
  - Enforce contiguous-only Head/Tail advancement in compaction
  - Add explicit RPOP claim flow (reverse scan from Tail-1)
  - Add Tail-side GC for RPOP claim keys

- Extend delta metadata pattern to Hash, Set, and ZSet:
  - Hash: wide-column decomposition + delta
  - Set: wide-column decomposition + delta
  - ZSet: delta on existing wide-column (PR #483)
  - Unified compactor, shared delta limits, transition plan per type

- Rename list_metadata_delta_design.md to collection_metadata_delta_design.md
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a wide-column storage format for Redis Sorted Sets, replacing the legacy single-blob storage with granular keys for metadata, members, and a score-based index. The implementation includes migration logic and support for standard ZSet commands. Review feedback highlights significant scalability risks, noting that several operations still load entire sets into memory or generate excessively large transaction batches, which could lead to OOM errors or cluster instability with large datasets.

Comment on lines +165 to +187
func (r *RedisServer) loadZSetMembersMap(ctx context.Context, key []byte, readTS uint64) (zsetLoadResult, error) {
// Check wide-column meta first.
meta, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
if err != nil {
return zsetLoadResult{}, err
}
if metaExists {
entries, err := r.scanZSetAllMembers(ctx, key, meta.Len, readTS)
if err != nil {
return zsetLoadResult{}, err
}
return zsetLoadResult{members: zsetEntriesToMap(entries), exists: true}, nil
}
// Fall back to legacy blob format.
value, exists, err := r.loadZSetAt(ctx, key, readTS)
if err != nil {
return zsetLoadResult{}, err
}
if !exists {
return zsetLoadResult{members: map[string]float64{}}, nil
}
return zsetLoadResult{members: zsetEntriesToMap(value.Entries), exists: true, fromLegacy: true}, nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The loadZSetMembersMap function loads the entire sorted set into an in-memory map. For large sets, this can lead to excessive memory consumption and potential OOM (Out of Memory) errors on the adapter node. This monolithic access pattern defeats the scalability benefits of the new wide-column storage format. Consider refactoring commands like ZADD, ZREM, and ZSCORE to use point lookups or range scans instead of loading the full set.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

}

value, _, err := c.server.loadZSetAt(context.Background(), key, c.startTS)
load, err := c.server.loadZSetMembersMap(context.Background(), key, c.startTS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the adapter commands, Lua scripts also load the entire ZSet into memory via loadZSetMembersMap. This poses a significant OOM risk for large sets processed within scripts. Consider providing Lua APIs that can perform partial updates or range scans on ZSets.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

Comment on lines +196 to +228
func (r *RedisServer) deleteZSetWideColumnElems(ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) {
meta, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
if err != nil {
return nil, err
}
if !metaExists {
return nil, nil
}

elems := []*kv.Elem[kv.OP]{
{Op: kv.Del, Key: store.ZSetMetaKey(key)},
}

// Use meta.Len to bound the scan. Each member has one member key and one
// score-index key, so we expect exactly meta.Len entries per prefix.
scanLimit := int(meta.Len)

// Scan member keys and score-index keys for individual deletion.
for _, prefix := range [][]byte{
store.ZSetMemberScanPrefix(key),
store.ZSetScoreScanPrefix(key),
} {
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
if err != nil {
return nil, errors.WithStack(err)
}
for _, kvp := range kvs {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}
}

return elems, nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

deleteZSetWideColumnElems performs a full scan of all member and score index keys and generates individual kv.Del operations for each. For large ZSets, this creates a massive transaction that can exceed Raft message size limits and cause cluster instability. Additionally, the scan itself can lead to OOM if the number of keys is very large. Consider implementing a more scalable deletion strategy, such as asynchronous background cleanup or using a single prefix deletion if the system constraints allow.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

return 0, wrongTypeError()
}
value, _, err := r.loadZSetAt(context.Background(), key, readTS)
load, err := r.loadZSetMembersMap(context.Background(), key, readTS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

zaddTxn (and similarly zincrby at line 1399) loads the entire ZSet map to perform updates. This is inefficient for large sets. Point lookups using the new wide-column keys should be used to check for member existence and retrieve old scores, allowing for O(1) updates regardless of the set size.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

変更ファイル: adapter/redis.go, redis_compat_commands.go, redis_compat_helpers.go, redis_compat_types.go, redis_lua_context.go, redis_retry.go, redis_retry_test.go, redis_storage_migration_test.go, store/zset_helpers.go(新規)


問題点

1. 新規ファイル store/zset_helpers.go に対するユニットテストが皆無(重要度: 高)

298行に及ぶ新規ファイルに含まれる以下の関数が一切テストされていません:

  • キー構築: ZSetMetaKey(), ZSetMemberKey(), ZSetScoreKey(), ZSetScoreScanPrefix()
  • レンジスキャン: ZSetScoreRangeStart(), ZSetScoreRangeEnd()
  • エンコーディング: EncodeSortableFloat64(), DecodeSortableFloat64()
  • デコーディング: ExtractZSetUserKey(), ExtractZSetScoreAndMember(), ExtractZSetMember()
  • ユーティリティ: PrefixEnd()

提案するテストケース (store/zset_helpers_test.go):

// TestEncodeSortableFloat64RoundTrip: 様々なスコアをエンコード→デコードしても等値を返す
// TestEncodeSortableFloat64SortOrder: エンコード後のバイト列がスコアの昇順でソートされること
//   エッジケース: -∞, -0.0, +0.0, +∞, NaN, math.MinFloat64, math.MaxFloat64
// TestZSetScoreKeyOrdering: 異なるスコアのキーをバイト順にソートすると昇順になること
//   (スコアインデックスの正確な順序が ZRANGE の正確さに直結する)
// TestExtractZSetUserKey: メタ/メンバー/スコアキーからユーザーキーを正しく抽出できる
//   エッジケース: ユーザーキーが空、ユーザーキーが別のプレフィックスを持つ場合
// TestExtractZSetUserKeyShortKey: 短すぎるキーで nil を返すことを確認(クラッシュ防止)
// TestPrefixEnd: 通常バイト列、空スライス、全0xFFバイトのケースを検証
2. ZSet の差分コミット(origMembers / fromLegacy)のロジックにテストなし(重要度: 中)

zsetTxnStateorigMembers (ロード時スナップショット) と fromLegacy フラグが追加されました。差分ベースのコミット(buildZSetElems)は ZSet の正確性に直結しますが、以下のシナリオのテストが見当たりません:

// TestZSetZAddRemoveDiffCommit: ZADD 後に ZREM した場合の差分が正確に Raft にコミットされる
// TestZSetLegacyFlagMigrationPath: fromLegacy=true のとき旧JSONを削除しワイドカラムに書き直す
// TestZSetConcurrentZAddNoLostUpdate: 並行 ZADD でメンバーが失われないことを確認
3. redis_storage_migration_test.go は既存テストの修正のみ

TestRedisZSetLegacyJSONReadThenRewriteToProto の内部検証が更新されていますが、新しいワイドカラムレイアウト(メタキー・メンバーキー・スコアインデックスキーの3種が正しく書かれるか)を直接確認するテストは追加されていません。


まとめ

新機能 テスト有無
store/zset_helpers.go 全関数 なし
EncodeSortableFloat64 のスコートソート順 なし
ZSet ワイドカラム差分コミット なし
fromLegacy マイグレーションパス 部分的(既存テスト更新のみ)

スコアインデックスの正確性は ZRANGE/ZRANGEBYSCORE の正確さに直結するため、特に EncodeSortableFloat64 の境界値テストを強く推奨します。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #483 の性能観点のレビューです。ZSet をワイドカラム形式に分解する設計変更自体は I/O 削減の方向性として正しいですが、以下の問題点があります。


🔴 重大: ZADD/ZINCRBY/ZREM がワイドカラムパスでも O(N) の全メンバーロード

該当箇所: adapter/redis_compat_commands.gozaddTxn / zincrby / zremrangebyrankInner(legacyパス) および adapter/redis_compat_helpers.goloadZSetMembersMap

// zaddTxn — 1メンバー追加でも全件スキャン
load, err := r.loadZSetMembersMap(context.Background(), key, readTS)
// → loadZSetMetaAt(1 I/O) + scanZSetAllMembers(N I/O) + zsetEntriesToMap(O(N) alloc)

ワイドカラム化の主なメリットは「単一メンバー操作を O(1) I/O にする」ことですが、現実装では loadZSetMembersMap が meta → 全件 scan → map 変換と O(N) I/O を実行しています。ZSet に 10,000 メンバーがあれば、ZADD 1件でも 10,001 回の I/O が発生します。

提案: ZADD/ZINCRBY は「古いスコアキーを取得する → member key のみ点読み (O(1) I/O)」で実現できます。zremrangebyrankInner の wide-column パスがすでに scanZSetScoreEntries(limit=e+1) で部分スキャンしている設計を、ZADD/ZINCRBY/ZREM 単体操作にも適用すべきです。


🟠 重要: 書き込みのたびに O(N) のマップコピーが 2 回発生

該当箇所: adapter/redis.go:1517-1520adapter/redis_compat_commands.go 各 write 操作、adapter/redis_lua_context.go:637-640

// loadZSetState — origMembers スナップショット
origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

// 呼び出し元 (例: zaddTxn) — さらにもう 1 コピー
members := make(map[string]float64, len(load.members))
for k, v := range load.members {
    members[k] = v
}

単一メンバー操作でも常に「全メンバーマップ × 2」のメモリを確保しています。N=10,000 なら 16 バイト × 10,000 × 2 = 320KB のアロケーションが毎回発生します。


🟠 重要: deleteZSetWideColumnElems が member/score 各 O(N) スキャンを 2 回実施

該当箇所: adapter/redis_compat_helpers.go

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),  // N 件スキャン
    store.ZSetScoreScanPrefix(key),   // N 件スキャン(1:1 対応なのに別スキャン)
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    ...
}

member キーと score インデックスキーは 1:1 対応しているため、member キーのみスキャンして score キーを再構成すれば 1 回のスキャンで済みます。FLUSHALL や DEL のホットパスで 2× の I/O が発生します。


🟡 軽微: buildZSetDiffElems の初期 cap が 0

該当箇所: adapter/redis_compat_helpers.go

elems := make([]*kv.Elem[kv.OP], 0)  // cap=0、差分サイズが不明

差分の最大サイズは 2*(len(origMembers)+len(newMembers))+1 で上界が取れます。事前確保でリアロケーションを防げます。


🟡 軽微: キー構築関数ごとに 3 アロケーション(ホットパスに多数呼出し)

該当箇所: store/zset_helpers.goZSetMetaKey / ZSetMemberKey / ZSetScoreKey

func ZSetMetaKey(userKey []byte) []byte {
    prefix := []byte(ZSetMetaPrefix)    // alloc 1: string → []byte
    comp := zsetUserKeyComponent(userKey) // alloc 2: intermediate buf
    buf := make([]byte, 0, len(prefix)+len(comp)) // alloc 3: final buf
    ...
}

ZADD 1件で meta/member/score の 3 キーを構築 → 9 アロケーション。len(ZSetMetaPrefix) は定数なのでコンパイル時定数化し、単一バッファに直接書き込む設計にするとアロケーションが 1 回になります。


まとめ

優先度 問題 影響
🔴 重大 ZADD 等が wide-column でも全件 O(N) I/O ロード 大規模 ZSet で性能劣化
🟠 重要 書き込み毎に O(N) map × 2 コピー メモリ圧力・GC 負荷増
🟠 重要 delete で member/score を別々にスキャン I/O 2×
🟡 軽微 buildZSetDiffElems cap=0 realloc によるパフォーマンスロス
🟡 軽微 キー構築で 3 alloc/キー ホットパスでの GC 圧

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

問題: スコアのみ変更時にメタキーが更新されず、競合検出が機能しない(線形化可能性違反)

buildZSetDiffElems のメタキー更新ロジック(store/zset_helpers.go の呼び出し元):

} else if len(origMembers) != len(newMembers) {
    // Only update meta if cardinality changed
    elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}

メンバー数が変わらない場合(スコア更新: ZADD/ZINCRBY で既存メンバーのスコアだけ変わるケース)、メタキー (!zs|meta|...) が書き込まれません。

一方、txnContext.loadZSetState で追跡される読み取りキーはメタキーのみです:

t.trackReadKey(store.ZSetMetaKey(key))

個々のメンバーキー (!zs|mem|...) やスコアインデックスキー (!zs|scr|...) は読み取りセットに追加されません。

問題のシナリオ(Lost Update):

時刻 T1 T2
t=10 ZSet {a:1.0, b:2.0} を読む。readSet: {ZSetMetaKey} ZSet {a:1.0, b:2.0} を読む。readSet: {ZSetMetaKey}
t=11 a のスコアを 3.0 に更新。メンバー数不変→メタキー書き込みなしZSetMemberKey(a)=3.0, ZSetScoreKey(a,3.0) を書き込み、ZSetScoreKey(a,1.0) を削除
t=12 a のスコアを 5.0 に更新。競合チェック: latestTS(ZSetMetaKey) > 10? → T2 がメタを更新していないため False → 競合未検出! ZSetScoreKey(a,1.0) を削除(T2 がすでに削除済み: noop)。ZSetScoreKey(a,5.0) を書き込み

結果: ZSetMemberKey(a)=5.0(T1 が上書き)だが、ZSetScoreKey(a,3.0)(T2 が書いたもの)が残留してスコアインデックスに不整合が生じます。ZRANGEBYSCORE などでゴーストエントリとして現れます。

修正案: メンバー数が変わらない場合でも、スコア変更があれば常にメタキーを更新する(値を同一のままでも書き込む)か、個々のメンバーキー・スコアインデックスキーも読み取りセットに追加する。

例えばメタキーを常に書き込む場合:

// len(origMembers) != len(newMembers) の条件を削除し、len(newMembers) > 0 なら常に更新
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
    return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})

この変更により、ZINCRBY/ZADD(スコア更新)でもメタキーへの書き込みが競合検出のアンカーとなり、線形化可能性が保たれます。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

P1: キー構築関数での多重アロケーション(ホットパス・ループ内)

store/zset_helpers.go:43–103

zsetUserKeyComponent() が中間バッファを毎回 make し、その後各キー構築関数(ZSetMemberKey, ZSetScoreKey 等)がさらに []byte(ZSetMemberPrefix) の変換バッファと最終バッファを確保している。1キー構築あたり 3アロケーション(prefix変換・中間component・最終buf)が発生する。

buildZSetWriteElemsbuildZSetDiffElems でメンバーループ内から ZSetMemberKey + ZSetScoreKey + MarshalZSetScore を呼ぶため、N メンバーの書き込みで 約 7N 回のアロケーションになる。

// 現状: ZSetMemberKey の例
func ZSetMemberKey(userKey, member []byte) []byte {
    prefix := []byte(ZSetMemberPrefix)     // alloc 1
    comp := zsetUserKeyComponent(userKey)  // alloc 2 (内部make)
    buf := make(...)                        // alloc 3
    ...
}

改善案:zsetUserKeyComponent を廃止し、各キー構築関数で 1 回の make に統合する。

func ZSetMemberKey(userKey, member []byte) []byte {
    prefixLen := len(ZSetMemberPrefix)
    buf := make([]byte, 0, prefixLen+zsetUserKeyLenSize+len(userKey)+len(member))
    buf = append(buf, ZSetMemberPrefix...)
    binary.BigEndian.AppendUint32(buf[len(buf):len(buf)+zsetUserKeyLenSize], uint32(len(userKey)))
    buf = buf[:len(buf)+zsetUserKeyLenSize]
    buf = append(buf, userKey...)
    buf = append(buf, member...)
    return buf
}

P2: buildZSetDiffElems の初期容量未指定

adapter/redis_compat_helpers.go:264

elems := make([]*kv.Elem[kv.OP], 0)  // cap指定なし

差分サイズに応じて複数回リアロケーションが発生する。削除/追加数は事前に分からないが、上限は len(origMembers)*3 + len(newMembers)*2 + 1 で見積もれる。あるいは小さい値(例 len(newMembers)*2+1)で事前確保するだけでも大幅に改善できる。


P3: 書き込みホットパスでの O(N) マップフルコピー

adapter/redis_compat_commands.go:1359–1362, 1406–1408 および redis_compat_commands.go zrem

zaddTxn, zincrby, zrem のいずれも、loadZSetMembersMap で全メンバーロード後にさらに全コピーを実行している:

members := make(map[string]float64, len(load.members))
for k, v := range load.members {
    members[k] = v
}

1メンバーのみ変更する ZADD/ZINCRBY/ZREM でも ZSet 全件のコピーが走る。差分計算(buildZSetDiffElems)には load.members(変更前)と newMembers(変更後)の両方が必要なので、コピー自体を完全に省くことは難しいが、origMembers として load.members を参照渡しのまま扱い、変更後のマップだけを別途構築する設計(例: 変更エントリのみ追跡する小さなデルタ構造)にすれば O(delta) に下げられる。


P4: MarshalZSetScore のループ内毎回アロケーション

store/zset_helpers.go:194

func MarshalZSetScore(score float64) []byte {
    buf := make([]byte, zsetScoreBinarySize)  // 毎回ヒープアロケーション
    binary.BigEndian.PutUint64(buf, math.Float64bits(score))
    return buf
}

buildZSetWriteElemsbuildZSetDiffElems のメンバーループ内で毎回呼ばれる。スコアバイト列を呼び出し元バッファに直接書く([8]byte スタック配列 + Value フィールドへのスライス)か、kv.Elem.Value の確保と統合することで GC 圧力を削減できる。


P5: localKeysPattern でのフルプレフィックススキャン(KEYS/SCAN コマンド)

adapter/redis.go:1190–1197

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {

ZSetMeta キーの length-prefix エンコーディングにより、他の内部プレフィックスと異なりパターンでスキャン範囲を絞れない。そのため KEYS/SCAN 時に 全 ZSet メタキーをフルスキャンしてメモリ上でフィルタリングしている。コード内コメントで言及されているが、ZSet 数が多い環境では他の型(list, hash 等)と比べて KEYS/SCAN コストが著しく高くなる。設計上の制限として既知であれば TODO コメントや issue として記録を残すことを推奨。


P6: rawKeyTypeAt における ZSet の常時デュアルルックアップ

adapter/redis_compat_helpers.go:25–26

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},
{typ: redisTypeZSet, key: redisZSetKey(key)}, // legacy blob fallback

型判定のたびに常に2回のストアアクセスが発生する。マイグレーション完了後もこのオーバーヘッドが残る。マイグレーション完了フラグ(ノード再起動後等)によるショートサーキット、または rawKeyTypeAt の呼び出し元で ZSet が wide-column 形式かどうかをキャッシュする仕組みがあると改善できる。


総評

今回の変更は「全件 blob 書き込み → 差分書き込み」への移行であり、ストレージ I/O の観点では大きな改善です。上記 P1〜P4 はそのメリットを活かすために メモリアロケーション数を削減する 方向の改善点です。P5・P6 は現状でも機能上問題ないですが、規模が大きくなると顕在化しやすいポイントです。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

対象: adapter: decompose ZSet storage into per-member wide-column keys

指摘事項: 2件


[重大] !zs| プレフィックスが PR #486knownInternalPrefixes に含まれていない (cross-PR 整合性問題)

関連箇所: store/zset_helpers.goZSetMetaPrefix, ZSetMemberPrefix, ZSetScorePrefix

本PRは !zs|meta|, !zs|mem|, !zs|scr| という新しいプレフィックスでZSetデータを格納する。
しかし PR #486adapter/redis_compat_types.go にある knownInternalPrefixes にはこれらが含まれていない。

両PRがマージされると、FLUSHLEGACY!zs|* キーを「レガシーな裸キー」と誤認識してZSetデータ全体を削除してしまう。

// PR #486 の knownInternalPrefixes(現状)
var knownInternalPrefixes = [][]byte{
    []byte("!redis|"),
    []byte("!lst|"),
    []byte("!txn|"),
    []byte("!ddb|"),
    []byte("!s3|"),
    []byte("!dist|"),
    // !zs| が欠けている → FLUSHLEGACY でZSetデータが消える
}

提案: PR #486knownInternalPrefixes[]byte("!zs|") を追加する。マージ順序に依存しないよう、双方のPRを調整してからマージすることを推奨。


[中] deleteZSetWideColumnElemsscanLimit がメタカウントに依存

箇所: adapter/redis_compat_helpers.godeleteZSetWideColumnElems

scanLimit := int(meta.Len)

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    // ...
}

meta.LenbuildZSetDiffElems により メンバー数が変化しない更新(スコア変更のみ)では更新されない
これ自体は正しい設計だが、もし何らかの理由でメタカウントが実際のメンバー数より小さくなった場合(例: 将来のバグ)、scanLimit によりメンバーキーやスコアインデックスキーの削除が不完全になる。

ScanAt のlimitを meta.Len * 2 とするか、limitなしスキャンを使う防御的な実装を検討されたい(パフォーマンストレードオフあり)。


確認できた正しい動作

  • マルチキー操作の原子性: buildZSetWriteElems / buildZSetDiffElems / buildZSetRemoveEntryElems が生成するKV操作群は単一の OperationGroup としてRaft経由でdispatchされるため、原子性が保たれている。
  • レガシー移行の原子性: wide-columnフル書き込みとlegacy blobの削除が同一バッチで行われるため、移行中の中間状態は発生しない。
  • スコアインデックスのエンコーディング: EncodeSortableFloat64 によりIEEE 754 float64がバイト比較可能な順序に正しく変換されている。negative/positive/zeroの符号ビット反転処理も正しい。
  • TTL追跡: ZSetキーに対して trackReadKey(redisTTLKey(store.ExtractZSetUserKey(key))) を追加しており、TTLの競合検出が正しく機能する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

1 件の重大なリスクと 1 件の軽微なリスクを確認しました。


[高] FLUSHALL/FLUSHDB が !zs| プレフィックスを削除しない (#486 との相互作用)

該当箇所: PR #486adapter/redis_compat_commands.go flushDatabase × 本 PR の store/zset_helpers.go

本 PR は ZSet データを以下の新プレフィックスに分解して格納します。

プレフィックス 用途
`!zs meta
`!zs mem
`!zs scr

一方、PR #486 で変更された FLUSHALL/FLUSHDB は !redis|!lst| のみを削除対象とし、!zs| プレフィックスは対象外です。

// PR #486 の flushDatabase(変更後)
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
// !zs|meta|, !zs|mem|, !zs|scr| は削除されない

両 PR がマージされると、FLUSHALL/FLUSHDB を実行しても ZSet データが残存します。ユーザーは Redis の FLUSHALL がデータを完全に消去すると期待するため、これは深刻な動作不一致です。

対処案: PR #486flushDatabase{Op: kv.DelPrefix, Key: []byte("!zs|")} を追加する。


[低] deleteZSetWideColumnElems のスキャン上限が meta.Len に依存

該当箇所: adapter/redis_compat_helpers.godeleteZSetWideColumnElems

scanLimit := int(meta.Len)
for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    // ← meta.Len より多くキーが存在した場合、末尾のキーが削除されない

通常は各書き込みトランザクション内でメンバー数と meta.Len が同期されるため問題になりませんが、万一 meta.Len が実キー数を下回る状態(例: 将来のバグ等による不整合)になると、削除しきれない孤立キーがストレージに残ります。ユーザーからは ZSet が削除されたように見えますが、内部には古いキーが残存します(ストレージリーク)。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

PR: adapter: decompose ZSet storage into per-member wide-column keys

問題あり(中程度)


1. ミュータブルな map を別名で渡している(latent aliasing bug)

該当箇所: bzPopMinLegacy および zremrangebyrankInner(レガシーフォールバックパス)

コード例(bzPopMinLegacy):

delete(load.members, popped.Member)  // load.members を直接変更
if err := r.persistZSetMembersTxn(ctx, key, readTS, load, load.members); err != nil {

問題: load.members をインプレースで変更したうえで、同じマップを load(origMembers として使用)と newMembers の両方として persistZSetMembersTxn に渡しています。

現在は安全な理由: レガシーパスでは load.fromLegacy == true が保証されるため、persistZSetMembersTxn 内の buildZSetDiffElems(key, load.members, newMembers) ブランチは実行されず、full-write パスが使われます。

潜在的リスク: 将来のリファクタリングや、データ不整合によって fromLegacy == false になった場合、buildZSetDiffElems は同一マップオブジェクト(origMembers == newMembers)を受け取り差分なし→要素数ゼロ→書き込みなし、という サイレントな no-op になります。結果としてメンバーが削除されずにコミットされ、ロストアップデート が発生します。

同様のパターン: zremrangebyrankInner レガシーパスでも同じ問題があります。

推奨修正:

// bzPopMinLegacy: newMembers は load.members のコピーを削除後に作成する
newMembers := make(map[string]float64, len(load.members))
for k, v := range load.members {
    newMembers[k] = v
}
delete(newMembers, popped.Member)
if err := r.persistZSetMembersTxn(ctx, key, readTS, load, newMembers); err != nil {

2. context.Background() の使用(既存の問題だが範囲拡大)

該当箇所: zaddTxn, zincrby, zrem など内部の loadZSetMembersMap(context.Background(), ...)

外部から渡された ctx(タイムアウト付き)がストレージ読み取りに伝播されていません。ディスパッチ側には伝播されているため致命的ではありませんが、ストレージ読み取りがハングした場合にキャンセルされません。既存のパターンの踏襲ではあるものの、今回の変更で影響範囲が広がっています。


3. meta.Len を scan limit に使用(軽微)

scanZSetAllMembersmeta.Len をそのままスキャン上限として使用しています。meta.Len が実際のメンバー数より小さい場合(例: 移行中の不整合)、全メンバーが返されない可能性があります。Raft の単一 OperationGroup 保証により通常は一致しますが、防御的に取り扱う価値があります。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


[P1] 読み取り専用アクセスでの不要な O(N) マップコピー

場所: adapter/redis.gotxnContext.loadZSetState

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

origMembersbuildZSetElemsbuildZSetStateElemsbuildZSetDiffElems でのみ参照され、dirty == true のときだけ実行される。ZSCORE・ZCARD・ZRANK 等の読み取り専用コマンドは dirty をセットしないため、このフルコピーは毎回無駄になる。メンバー数が多い ZSet では無視できないアロケーションコストとなる。

改善案: dirty フラグが立ったとき(最初の変更時点)に遅延コピーするか、origMembers を nil のまま保持して commit 時に buildZSetWriteElems(full write)にフォールバックする。


[P2] zaddTxn / zincrby での二重マップアロケーション

場所: adapter/redis_compat_commands.gozaddTxnzincrby

load, err := r.loadZSetMembersMap(...)        // map を新規確保
members := make(map[string]float64, len(load.members))  // さらにコピー
for k, v := range load.members {
    members[k] = v
}

loadZSetMembersMapzsetEntriesToMap で既に新規マップを確保して返す。その直後に呼び出し元が同サイズのマップを作って全要素をコピーしている。O(N) アロケーション + コピーが2回発生している。persistZSetMembersTxnload.members を diff の基準として使う設計上、コピーが必要なこと自体は理解できるが、loadZSetMembersMap が「変更用コピー」と「元スナップショット」を分けて返せばアロケーションを1回に削減できる。


[P3] deleteZSetWideColumnElems での二回の ScanAt

場所: adapter/redis.godeleteZSetWideColumnElems

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    ...
}

各プレフィックスが連続した範囲を占めていれば1回の広めのスキャンで代替できる可能性がある。構造上2スキャン不可避であれば問題なし。ただし scanLimitint(meta.Len) を使っている点は正しく有界化されており問題なし。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

問題なし。

確認した主要ポイント

Wide-column ZSet の meta/member/score キー整合性:

  • buildZSetWriteElems: meta.Len = len(members) を一括書き込み → 正しい
  • buildZSetDiffElems: カウント変化時のみ meta を更新。スコアのみ変更(カウント不変)の場合 meta を書かないのは Len の意味上正しい
  • buildZSetRemoveEntryElems: newLen = currentLen - len(toRemove) → 正しい

Conflict detection の正確性(zremrangebyrankInner wide-column パス):

  • 並行 ZADD がメンバー追加した場合: meta キーが更新される → 競合検出 ✓
  • 並行 ZADD が削除対象メンバーのスコアを変更した場合: member キーが更新される → 競合検出 ✓
  • 並行 ZADD が削除対象外メンバーのスコアを変更した場合: 異なるデータへの操作なので競合不要 → 正しい
  • dispatchElems(ctx, true, readTS, elems) の write-set ベース競合チェックで十分にカバーされている

Legacy blob → wide-column マイグレーションのアトミック性:

  • buildZSetStateElemsfromLegacy パス: 新フォーマット書き込みと旧 redisZSetKey 削除を同一 Raft operation group として送信 → アトミック ✓

TTL と ZSet データ本体の整合性:

  • loadZSetState が TTL チェックをメンバー読み出しより先に行い、期限切れの場合はメンバーを空として扱う → 一貫している ✓

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys

問題1 (MEDIUM): deleteZSetWideColumnElems のスキャン上限が meta.Len に依存しており、孤立キーが残存するリスク

該当箇所: adapter/redis_compat_helpers.go deleteZSetWideColumnElems()

scanLimit := int(meta.Len)
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)

メンバーキー (!zs|mem|) とスコアインデックスキー (!zs|scr|) のスキャン上限を meta.Len で制限している。meta.Len が実際のメンバー数より小さい場合(例:将来的なバグや部分適用による不整合が起きた場合)、超過分のメンバーキー・スコアキーが削除対象に含まれず、ZSetを削除したにもかかわらず孤立キーが残存する。

現状、すべての書き込みはRaftを通じてアトミックにコミットされるため、通常運用中にmetaが不正確になることは想定されない。ただし、将来的な不整合に対して防御的に PrefixEnd スキャン(上限なし)で全削除する設計の方が安全性は高い。


問題2 (MEDIUM): zremrangebyrankInner での meta.Len を用いた新規メタ計算

該当箇所: adapter/redis_compat_commands.go zremrangebyrankInner()

entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS)
...
s, e = normalizeRankRange(start, stop, len(entries))
toRemove := entries[s : e+1]
elems, err := buildZSetRemoveEntryElems(key, toRemove, meta.Len)  // ← meta.Len を currentLen として使用

buildZSetRemoveEntryElemsnewLen = currentLen - len(toRemove) でメタを更新する。
スキャンが返す len(entries)meta.Len より小さい場合(メタ不整合状態)、newLen = meta.Len - len(toRemove) は実際の残存メンバー数より大きくなり、メタの不整合が次の操作へ伝播する。

正確には newLen = len(entries) - len(toRemove) を用いるか、スキャン結果の実測値をmetaに反映すべき。


問題3 (確認済み/問題なし): bzPopMinLegacy のpop順序

zsetMapToEntries(load.members)sortZSetEntries でスコア昇順ソートされるため、entries[0] が最小スコアメンバーであることを確認済み。データロストなし。


問題4 (確認済み/問題なし): レガシー→ワイドカラム移行のアトミック性

persistZSetMembersTxn + buildZSetWriteElems + Del redisZSetKey は単一の dispatchElems 呼び出しでRaftにアトミック送信される。移行途中での中断によるデータロストはない。


問題5 (確認済み/問題なし): buildZSetDiffElems のスコア変更時のメタ更新

スコア変更でメンバー数が変わらない場合、メタ (Len) を更新しない条件 len(origMembers) != len(newMembers) は正しい。メタはカーディナリティのみを保持するため、スコアのみの変更でメタ更新は不要。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys


結論: 潜在的なデータ可視性リスクあり(要確認)


懸念事項

⚠️ scanZSetAllMembersmeta.Len を上限として使用(adapter/redis_compat_helpers.go

func (r *RedisServer) scanZSetAllMembers(ctx context.Context, key []byte, memberCount int64, readTS uint64) ([]redisZSetEntry, error) {
    return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)
}

meta.Len が実際のメンバー数より小さい値になった場合(将来のバグや手動操作などで不整合が生じた場合)、スキャン結果が途中で打ち切られ、超過分のメンバーが読み取り・更新操作からサイレントに見えなくなります。該当メンバーはストアには存在し続けるため、アプリケーション層から見ると「消えた」状態になります。

なお、削除パス(deleteZSetWideColumnElems)は math.MaxInt を使用しており、この問題を回避しています。読み取り・更新パスも同様に完全スキャンする方法を検討するか、meta.Len との不整合を検出する仕組みがあるとより安全です。

⚠️ buildZSetRemoveEntryElems が読み取り時の meta.Len に依存(adapter/redis_compat_helpers.go

zremrangebyrankInner および bzPopMinWideColumn では、readTS 時点の meta.LencurrentLen として使い、新しいメタ値を計算しています:

func buildZSetRemoveEntryElems(key []byte, entries []redisZSetEntry, currentLen int64) ([]*kv.Elem[kv.OP], error) {
    newLen := currentLen - int64(len(entries))

currentLen(= meta.Len)が既に不正確な値であった場合、newLen も不正確になり、以降の操作でのスキャン上限が誤った値に設定されます。不整合が連鎖的に伝播するリスクがあります。


問題なし(設計が正しいと確認した箇所)

✅ レガシー→ワイドカラム移行のアトミック性

buildZSetStateElemsst.fromLegacy パスは、ワイドカラムキーの書き込みとレガシーblobの削除を単一の Pebble バッチで実行します。Pebble バッチはアトミックなので、部分的な移行状態(ワイドカラムとレガシーが混在する状態)は発生しません。

✅ 削除パスの完全性

deleteZSetWideColumnElemsmath.MaxInt でスキャンするため、meta.Len の不整合に関わらず、すべてのメンバーキー・スコアインデックスキーを削除対象にできます。

buildZSetDiffElems の競合検出

ZSet 操作は meta キーを常に読み取り(loadZSetMetaAt)、trackReadKey(store.ZSetMetaKey(key)) で read-set に登録します。PR #499 の SSI 検証と組み合わせることで、並行書き込みによる競合は検出されます。

✅ TTL キーの扱い

レガシー削除時(len(st.members) == 0 && st.fromLegacy)は TTL キーも削除。ワイドカラム移行時(st.fromLegacy && len(st.members) > 0)は TTL キーを保持。いずれも意図した動作と一致しています。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


1. [高影響] 削除時の無制限スキャン (, deleteZSetWideColumnElems)

scanLimit := math.MaxInt

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    ...
    for _, kvp := range kvs {
        elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
    }
}

メンバー数が多い ZSet を削除する際に、member prefix + score prefix を全件スキャンして全キーをメモリに展開する。ZSet が 100万メンバーあれば 200万エントリを elems としてアロケーションする。meta.Len が取得済みであれば、それを scanLimit として上限を設けるか、FSM の kv.DelPrefix を使ったプレフィックス削除に切り替えるべき(コメントには「atomicity のため kv.DelPrefix は混在不可」と書かれているが、2フェーズに分けるか専用のバッチ命令として扱うことを検討してほしい)。


2. [中影響] ZSet ロード時の O(N) map コピー (複数箇所)

loadZSetStatezaddTxnzincrbyzrembzPopMinLegacyluaScriptContext.zsetState のそれぞれで、ロードしたメンバーを origMembers としてフルコピーしている:

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

ZSet に N メンバーあれば、トランザクションが参照するたびに O(N) のアロケーションとコピーが発生する。read-only なアクセス(ZRANGE等)でも loadZSetState 経由でスナップショットコピーが走る。差分検出用のスナップショットが必要なのは書き込みを行った場合のみなので、「dirty になった時点で初めてスナップショットを取る」遅延コピー戦略が有効。


3. [中影響] localKeysPattern でのパターン未絞り込みスキャン ( ~1194行)

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {
    return nil, err
}

他の型(List 等)が listPatternScanBounds(prefix, pattern) でパターンに応じてスキャン範囲を絞っているのに対し、ZSet meta は ZSetMetaPrefix 全体をスキャンする。KEYS/SCAN コマンドのたびに ZSet を持つ全キーを走査することになり、ZSet キー数が多いストアでは著しく遅くなる。ZSet meta のキーエンコーディングが長さプレフィックス付きのため単純なパターンマッチが難しい事情があるが、少なくとも上限 (limit) を設けるか、meta キーからユーザーキーを抽出したあとにパターンフィルタリングを行う構成が望ましい。


4. TYPE コマンドで ZSet に対して 2回読み ( ~588行)

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},   // 新フォーマット
{typ: redisTypeZSet, key: redisZSetKey(key)},         // legacy blob fallback

関数が最初にマッチした型を返す構造であれば、移行済みキー(meta あり)では meta を見て返るためオーバーヘッドは小さい。一方、未移行キー(legacy blob のみ)では meta ミス + legacy ヒットで 2 reads になる。移行が完全に終わったあとは legacy エントリの削除と合わせてこの 2重チェックも除去できる点を記録しておくこと(移行期限の目安があればコードにコメントを)。


5. buildZSetDiffElems の過剰な cap 事前確保

elems := make([]*kv.Elem[kv.OP], 0, (len(origMembers)+len(newMembers))*2+1)

全メンバーが変更される最悪ケースを見越した容量だが、1メンバーだけ変更する典型ケース(ZINCRBY 等)では確保容量が実際の使用量を大幅に上回る。len(origMembers)len(newMembers) の差の絶対値 + 変更数をおおまかに見積もるか、固定の小さめ初期容量 + 必要に応じた grow で対応する方がメモリ効率が良い。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: adapter: decompose ZSet storage into per-member wide-column keys (#483)


問題 1 (重大): zremrangebyrankInnermeta.Len がずれると zombie ZSet が生成される

ファイル: adapter/redis_compat_commands.go

func (r *RedisServer) zremrangebyrankInner(...) error {
    meta, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
    // ...
    s, e := normalizeRankRange(start, stop, int(meta.Len))  // ← meta.Len を使う
    entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS)
    s, e = normalizeRankRange(start, stop, len(entries))    // ← 実際のスキャン結果で再計算
    toRemove := entries[s : e+1]
    elems, err := buildZSetRemoveEntryElems(key, toRemove, meta.Len)  // ← meta.Len 基準でnewLen計算

buildZSetRemoveEntryElems の内部:

newLen := currentLen - int64(len(entries))  // currentLen = meta.Len

シナリオ: meta.Len = 10、実際のメンバー数 = 6(過去の不整合で発散している場合)で ZREMRANGEBYRANK 0 9(全削除)を実行すると:

  • スキャンで6エントリ取得 → 全削除
  • newLen = 10 - 6 = 4(正は4だが実際のメンバーは0)
  • meta キーに Len=4 で書き込まれて残る

結果: 全メンバーが削除されたのに meta が残り、ZSet が "4メンバー存在"と見なされる zombie 状態になる。以降の loadZSetMembersMap はメタを見てwidecol pathを選び、スキャン結果が空のzsetLoadResultを返す。ZCARDは0を返すが、TYPEzsetを返し、DELを呼ぶまで論理的に存在し続ける。

対策案: buildZSetRemoveEntryElems に渡す currentLen として meta.Len ではなく len(entries) を用いてrescan後の実態に合わせる、もしくは全エントリ削除時は meta.Len <= len(toRemove) を確認して meta を必ず削除する。


問題 2: buildZSetDiffElems は orphaned キーをクリーンアップしない

ファイル: adapter/redis_compat_helpers.go

func buildZSetDiffElems(key []byte, origMembers, newMembers map[string]float64) ([]*kv.Elem[kv.OP], error) {
    // origMembers にないキーは無視
    for member, oldScore := range origMembers {
        // origMembers に含まれるものだけ削除
    }
}

origMembers はトランザクション開始時点でロードしたスナップショットであるため、KVストア内に orphaned な wide-column キー(過去の partial write や migration の失敗で残ったもの)がある場合、それらは diff の対象外となりクリーンアップされない。
これは直接的なデータロストではないが、score index の不整合(スコアキーが残るが member キーが消えるなど)により 範囲スキャン系コマンドが余分なエントリを返したり、Len カウントとスキャン結果がずれる原因となる。

deleteLogicalKeyElemsdeleteZSetWideColumnElems は prefix scan で全削除するため DEL 時は安全だが、部分操作(ZREM等)時は前述の問題が残る。


問題 3: Legacy → wide-column 移行中にメタキーと TTL 削除が分離している

ファイル: adapter/redis_compat_commands.go, buildZSetStateElems

case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},
    }, nil

Legacy ZSet が空になった場合、legacy blob と TTL キーを削除するが、TTL キーを持つ wide-column ZSet(legacy からの移行途中で TTL が付いていた場合)のメタキーが残る可能性がある。このパスは fromLegacy=true かつメンバー0なので wide-column キーは存在しないはずだが、移行途中の状態(legacy blob + wide-column meta が両方存在)のケースが考慮されているか不明。


問題なし (確認済み)

  • deleteLogicalKeyElems での wide-column ZSet 全削除は deleteZSetWideColumnElems が prefix scan により orphaned キーも含めて削除するため適切
  • buildZSetWriteElems はメタ + メンバーキー + スコアキーを1トランザクションで書き込むため atomicity は保たれている
  • legacy blob の削除と wide-column の書き込みは同一トランザクションに含まれており、移行の中間状態は観測されない

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

✅ 正しい実装

meta/member/score キーの原子的コミット
buildZSetWriteElems / buildZSetDiffElems で生成された全 elem が単一の Raft エントリ内に収められ、ApplyMutations で一括適用される。部分適用によるメタとメンバーキーの乖離は発生しない。

deleteLogicalKeyElems でのワイドカラムキー削除
DEL_PREFIX との混在を避け、メタ・メンバー・スコアインデックスを個別スキャンして明示的に Del elem に変換している。Raft ログの意味論上正しい。


⚠️ 問題点

[重大] スコアのみ変更時にメタキーが更新されず、SSI 検出が機能しない

buildZSetDiffElems (adapter/redis_compat_helpers.go) では、メタキーの更新条件が len(origMembers) != len(newMembers) のみ。メンバー数が変わらずスコアだけ変更された場合、ZSetMetaKey への書き込みが発生しない。

} else if len(origMembers) != len(newMembers) {
    // ← スコアのみ変更時はここに来ない → meta key への write なし
}

一方、loadZSetState (adapter/redis.go) では t.trackReadKey(store.ZSetMetaKey(key)) のみをリードセットに追加している。

結果として:

Txn A: ZSet を読み取り (ZSetMetaKey をトラッキング)
Txn B: 同 ZSet のスコアを更新 (メンバー数不変 → ZSetMetaKey 未更新)
Txn A: コミット → checkReadConflicts が ZSetMetaKey の変化を見ないため競合未検知

PR #499 の SSI 検証 (checkReadConflicts) が ZSet スコア変更に対して盲目になる。

対策案: スコア変更時にも ZSetMetaKey に「バージョンスタンプ」として書き込む(Len は変えずともダミー Put する)か、または loadZSetState で読み取った各メンバーキー (ZSetMemberKey) もリードセットに追加する。

[要確認] レガシー blob キーの read-set トラッキング除外

変更前: loadZSetStatet.trackReadKey(redisZSetKey(key)) (レガシー blob をトラッキング)
変更後: t.trackReadKey(store.ZSetMetaKey(key)) のみ

レガシー形式 ZSet(ZSetMetaKey が存在しない)に対して loadZSetState が呼ばれた場合、redisZSetKey が read set に含まれない。trackTypeReadKeys では両キーが追加されているため、型チェックを経由するパスは保護されているが、型チェックを経由しないコードパスが将来追加された場合に競合検知漏れが生じるリスクがある。

loadZSetState 内で fromLegacy の場合も t.trackReadKey(redisZSetKey(key)) を追加することを推奨する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys


要確認: 1件の潜在的データ損失パス + 1件の事前存在バグ


[要確認] buildZSetStateElems の非legacyな空ZSet処理でTTLキーが残存する

場所: adapter/redis_compat_helpers.gobuildZSetStateElems

case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)}, // ← TTL削除あり
    }, nil
case len(st.members) == 0:
    // wide-columnのDiff削除: meta + member/score keys を削除
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members) // ← TTLキー削除なし

シナリオ:

  1. ZSetに TTL を設定 → メンバーを追加
  2. トランザクション内で全メンバーを ZREM → コミット時に meta/member/score キーは削除されるが TTLキー (!redis|ttl|{key}) は残存
  3. 同一キーに再び ZADD(TTL 未設定)
  4. loadZSetState が TTL チェックを先行実行 → 古い TTL が期限切れならば新ZSetのデータが「存在しない」扱いになりデータが不可視化される

補足: 旧コード(buildZSetElems)も同様に TTL を削除していなかったため、本PRが導入した新規バグではなく事前存在の問題です。ただし、wide-column化によりデータが複数キーに分散したことで問題のインパクトが変わる可能性があります。legacy パス(st.fromLegacy == true)では TTL 削除が追加されており一貫性がないため、非legacy パスにも同様の処理を追加することを推奨します。


[確認済み/問題なし] その他の項目

1. buildZSetRemoveEntryElemsmeta.Len 依存
newLen = currentLen - int64(len(entries)) の計算で meta.Len の正確性に依存しています。ただし、すべての書き込みパス (buildZSetWriteElems, buildZSetDiffElems) が同一 Raft エントリ内で meta.Len を原子的に更新しており、通常運用でのズレは発生しません。deleteZSetWideColumnElems が防御的に math.MaxInt スキャンを使用している点も確認しました。

2. legacy → wide-column マイグレーションの原子性
buildZSetStateElems の legacy マイグレーションケース(wide-column書き込み + legacy blob削除)が単一の Raft エントリで発行されており、部分的な移行状態が永続化されるリスクはありません。

3. scanZSetAllMembersmeta.Len を上限とするスキャン
meta.Len が実際のスコアインデックスキー数と不整合が生じた場合、余剰エントリが不可視になるリスクがありますが、(2) の通り原子性が保証されているため通常発生しません。

4. deleteLogicalKeyElems による完全削除パス
persistZSetMembersTxn の空ケースは deleteLogicalKeyElems を経由しており、TTL・legacy blob・wide-columnキーすべてが削除されます。明示的な DEL コマンド経由では問題ありません。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

PR #483adapter: decompose ZSet storage into per-member wide-column keys


✅ 正しい点

  • origMembers スナップショット: zsetTxnState.origMembers はロード時に取得するスナップショットで、per-transaction state(txnContext)内での使用のため並行アクセスなし。diff-based commit のアプローチは正しい。
  • レガシー移行の原子性: blob→wide-column 移行は単一トランザクション内で行われるため、失敗時に中間状態が残らない。トランザクション retry 時も fresh な txnContext で再ロードされる。
  • trackTypeReadKeys への ZSetMetaKey 追加: SSI(PR store: add read-set validation to ApplyMutations for SSI #499)との連携で ZSet メタキーを read set に追加することで、ZSet 操作に対する read-write 競合検出が正しく機能する。

⚠️ 懸念: routeKey()!zs| プレフィックスを認識しない

ファイル: kv/shard_key.go(変更なし、但し PR #483 の新キー形式に影響)

normalizeRouteKey は以下のプレフィックスのみ対応:

  • !redis| → ユーザキー抽出
  • !ddb| → DynamoDB テーブルキー
  • S3 キー
  • !lst| → リストユーザキー

!zs|meta|!zs|mem|!zs|scr| に対応するハンドラが存在しない。

func normalizeRouteKey(key []byte) []byte {
    if user := redisRouteKey(key); user != nil { return user }
    if table := dynamoRouteKey(key); table != nil { return table }
    if user := s3keys.ExtractRouteKey(key); user != nil { return user }
    if user := store.ExtractListUserKey(key); user != nil { return user }
    return key  // ← !zs| キーはそのままルーティングキーとして使われる
}

マルチシャード構成での影響:

  1. ZSetMetaKey(!zs|meta|<len><userKey>) がユーザキーとは別のシャードにルーティングされる可能性がある
  2. ZSet 書き込みミューテーション(!zs|meta|!zs|mem|!zs|scr|)も同じルーティングロジックを使うため、書き込みと SSI read-key チェックは同じシャードに行く(一貫性はある)
  3. ただし、ユーザキー自体とは別シャードに ZSet データが分散する可能性があり、localKeysPattern などの KEYS スキャンや TTL 処理に影響する

提案: shard_key.gonormalizeRouteKey に ZSet 内部キーのルーティング正規化を追加する:

if user := store.ExtractZSetUserKey(key); user != nil {
    return user
}

ℹ️ 軽微な注意点

  • KEYS パターンスキャン: localKeysPattern での ZSet メタプレフィックスのフルスキャンは、ZSet 数が多い場合にレイテンシが増加する可能性があるが、既存の他スキャンと一貫した実装である。
  • bzPopMinWideColumn のスコアエントリスキャン: scanZSetScoreEntries で取得した結果を元に削除 ops を構築するが、trackTypeReadKeys で ZSet メタキーが read set に追加されるため、SSI により他トランザクションの介入が検出される。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

⚠️ 指摘事項


1. loadZSetState — 読み取り専用ロードでも無条件に O(N) の origMembers コピーが発生

場所: adapter/redis.go / txnContext.loadZSetState

ZSet を読み取るたびに全メンバー分のマップをディープコピーしている。トランザクション内で ZSet を参照するだけで dirtyfalse のまま終わるケース(例: ZRANGE のみ)でも N 個の float64 値のアロケーション+コピーが走る。origMembers が実際に必要になるのは初回書き込み時だけなので、コピーをその時点まで遅延させる(コピーオンライト)ことで、読み取り専用パスのアロケーションをゼロにできる。


2. zaddTxn / zremloadZSetMembersMap が返す新規マップを即座に再コピー

場所: adapter/redis_compat_commands.go

loadZSetMembersMap は内部で zsetEntriesToMap により新規マップを生成して返す。呼び出し元でさらに同じサイズのマップを make してループコピーしているが、load.members は呼び出し元が所有する fresh なマップであるため再コピーは不要。そのまま使えばアロケーション 1 回分を削減できる。


3. deleteZSetWideColumnElemsmath.MaxInt 上限の 2 回フルプレフィックススキャン

場所: adapter/redis_compat_helpers.go

メンバーキーとスコアインデックスキーの 2 プレフィックスを上限なしでスキャンし、全キーをメモリ上のスライスに展開している。10K メンバー超の ZSet では 20K 超の要素を一度にメモリに乗せることになる。DelPrefix と他のミューテーションを同一 Raft リクエストに混在できないという制約は理解できるが、スキャン上限として meta.Len * 2 + バッファ を使うことで孤立キー数を限定しつつメモリ使用量を抑えられる(孤立キー除去を犠牲にするトレードオフあり)。


(参考) 改善点

  • zrangeRead: 以前は常にエントリをコピーしていたが、reverse 時のみコピーするよう修正されており無駄なアロケーションが削減されている。
  • buildZSetDiffElems: 差分のみを書き込む設計により、変更量が少ない場合の書き込み I/O を大幅に削減。レガシーブロブの全書き込みと比べて正しい方向性。
  • scanZSetScoreEntries: スコアインデックスキーが自然ソート済みのため、インメモリソートが不要になっている点は性能上の利点。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

カバーされているテスト ✅

  • store/zset_helpers_test.go (新規): エンコーディング・キー構築・抽出関数を網羅(EncodeSortableFloat64 の順序・ラウンドトリップ・-0.0 正規化、ZSetMeta の encode/decode、PrefixEndExtractZSetUserKeyExtractZSetScoreAndMemberIsZSetInternalKey
  • adapter/redis_zset_test.go (新規): buildZSetDiffElems を 5 ケースでカバー(スコア変更・メンバー削除・追加・無変更・全削除)
  • TestNormalizeRetryableRedisTxnErrZSetKey: ZSet 内部キー (meta/mem/scr) のエラー正規化
  • TestRedisZSetLegacyJSONReadThenRewriteToProto: ZADD による legacy blob → wide-column マイグレーション

テスト不足 ❌

1. buildZSetRemoveEntryElems にテストなし(重大)

BZPOPMINZREMRANGEBYRANK の wide-column パスで使用されますが、単体テストがありません。

提案するテストケース:

// TestBuildZSetRemoveEntryElems_UpdatesMeta (newLen > 0 → meta の Len が更新される)
// TestBuildZSetRemoveEntryElems_DeletesMeta (newLen == 0 → meta キーが Del される)
// TestBuildZSetRemoveEntryElems_DeletesMemberAndScore (member キーと score-index キーが両方 Del される)
2. buildZSetStateElems にテストなし

マイグレーションの 4 分岐(legacy 空非 legacy 空legacy 非空新規 ZSet既存 ZSet の差分)に対する単体テストがありません。

// TestBuildZSetStateElems_LegacyEmpty (legacy+空 → legacy blob と TTL キーを Del)
// TestBuildZSetStateElems_NewZSet (origMembers 空 + 非 legacy → buildZSetWriteElems 相当)
// TestBuildZSetStateElems_LegacyMigration (fromLegacy=true + 非空 → wide-column 書き込み + legacy blob Del)
// TestBuildZSetStateElems_Diff (既存 wide-column → buildZSetDiffElems 相当)
3. deleteZSetWideColumnElems にテストなし
  • meta が存在しない場合に nil, nil を返すことの確認
  • meta が存在する場合に meta・member・score-index キー全てが Del されることの確認
4. loadZSetMembersMap / loadZSetMetaAt / scanZSetScoreEntries に単体テストなし

wide-column の load パスがテストされていません:

// TestLoadZSetMembersMap_WideColumn (wide-column meta あり → members を返す)
// TestLoadZSetMembersMap_LegacyFallback (meta なし + legacy blob あり → fromLegacy=true)
// TestLoadZSetMembersMap_NotExists (meta なし + legacy なし → exists=false)
5. ZREMRANGEBYRANKBZPOPMIN の wide-column パスに統合テストなし

zremrangebyrankInner の wide-column ブランチと bzPopMinWideColumn は、loadZSetMetaAtscanZSetScoreEntriesbuildZSetRemoveEntryElems の新しいコードパスを通りますが、integration test が存在しません。

6. KEYS コマンドでの ZSet meta キー取得にテストなし

localKeysPattern に追加された ZSet meta prefix のスキャン (!zs|meta|... のスキャン + collectUserKeys によるフィルタリング) が、adapter/redis_keys_pattern_test.go に含まれていません。

// TestLocalKeysPattern_ZSetKeys
// ZSet (wide-column) を作成した後 KEYS * が正しくユーザーキーを返すことを確認
7. マイグレーションパスのテストが ZADD のみ

ZINCRBYZREMZREMRANGEBYRANK の legacy → wide-column マイグレーションパスに対するテストがありません。TestRedisZSetLegacyJSONReadThenRewriteToProto 相当のテストをこれらのコマンドにも追加することを推奨します。

8. redisVisibleUserKey の ZSet 対応にテストなし

adapter/redis_keys_pattern_test.go や内部テストに、ZSet 内部キー (ZSetMetaKey/ZSetMemberKey/ZSetScoreKey) に対して redisVisibleUserKey がユーザーキーを返すことを確認するケースがありません。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

問題なし。

以下の点を確認しました:

  • origMembers スナップショットのスレッド安全性: loadZSetState 内でのスナップショット作成は for mk, mv := range load.members による明示的なコピーであり、その後の変更と分離されている。txnContext はコネクションごとの単一ゴルーチンで処理されるため競合なし。
  • ロック順序: leaderClientsMu の RLock/Lock パターンにダブルチェックロックが実装されており、一貫したロック順序を維持。新規のデッドロックリスクなし。
  • diff ベースのコミットパス (buildZSetStateElems): origMembers と現在の members の差分から削除・追加を生成。ワイドカラムキー(メタ・メンバー・スコアインデックス)の更新が一括でディスパッチされ、アトミック性が保たれている。
  • 読み取りキーの追跡変更: trackTypeReadKeysredisZSetKey から store.ZSetMetaKey に変更。すべての変更がメタキーを経由するため、競合検出に漏れなし。
  • ゴルーチンリーク / チャネルブロック: 新規ゴルーチン生成なし。コンテキストキャンセルは既存パターンを踏襲。
  • Raft リーダー切り替え: txnContext はリトライループ内で毎回再生成され、startTS も取り直されるため、リーダー切り替え時の一貫性を維持。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

問題あり


1. 単一メンバー操作でも全メンバーをロードする(重大)

場所: adapter/redis_compat_commands.gozaddTxn, zincrby, zrem が呼ぶ loadZSetMembersMap

ZADD/ZINCRBY/ZREM で1件だけ追加・更新・削除する場合も、loadZSetMembersMapscanZSetAllMembers によって 全 N メンバーをスキャンしている。wide-column 形式に分解した目的(メンバー単位の O(1) アクセス)が、ミューテーション系パスでは実現できていない。

例: 10,000 件のメンバーを持つ ZSet への ZADD key 1.0 member は、10,000 件の KV 読み取りを伴う。

改善案: diff-based commit を維持しつつ、差分対象メンバーだけをロードする専用パス(loadZSetMember(key, member, readTS))を導入する。全ロードが必要なのはコマンド仕様上避けられない ZRANGEZREMRANGEBYRANK 系のみで十分。


2. ZSet ロード時に origMembers の二重マップ確保(中程度)

場所: adapter/redis.goloadZSetState, adapter/redis_lua_context.gozsetState

ZSet をロードするたびに:

  1. loadZSetMembersMap 内で zsetEntriesToMap により members マップを確保(O(N))
  2. 直後に全件ループで origMembers マップをコピー(O(N) × 2)

read-only な ZSet アクセス(ZSCORE, ZRANK, ZRANGE 等)では origMembers は使われないため、このコピーは無駄になる。

改善案: dirty フラグが立った時点で初めて origMembers を確保する遅延コピー (copy-on-write) にするか、origMembers を持たず commit 時に現在値を再取得する方式を検討する。


3. localKeysPattern の ZSet メタキースキャンがパターン非考慮(中程度)

場所: adapter/redis.golocalKeysPattern 内の追加ブロック

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {

他のキータイプが listPatternScanBounds(prefix, pattern) でスキャン範囲をパターンに絞るのに対し、ZSet メタキーは 全プレフィックス範囲 をスキャンしてから collectUserKeys でフィルタリングしている。KEYS "foo*" のような限定パターンであっても全 ZSet メタキーを走査するため、ZSet 数が多い環境では KEYS コマンドのレスポンスタイムが悪化する。

改善案: store.ZSetMetaPrefix 向けにも listPatternScanBounds と同等のパターン境界計算を追加する。


4. deleteZSetWideColumnElemsmath.MaxInt でスキャン(軽微・正確性トレードオフ)

場所: adapter/redis_compat_helpers.godeleteZSetWideColumnElems

member/score 両プレフィックスを上限なしでスキャンし、個別削除エレメントを O(N) 件メモリ上に構築してから1トランザクションで送信している。大規模 ZSet(数万件)では単一の Raft エントリが非常に大きくなり、スナップショットサイズや apply レイテンシに影響する可能性がある。

現状はアトミック削除のためにこの方式が必要と理解しているが、meta.Len を使った事前確保(make([]*kv.Elem, 0, meta.Len*2+1))でリアロケーションを削減できる。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

問題あり: TTL 整合性バグ(Critical)


1. [Critical] 期限切れキーへの ZADD が TTL キーをクリアしない

問題箇所: buildZSetStateElemslen(st.origMembers) == 0 パス

loadZSetState は TTL 期限切れのキーを「存在しない ZSet」として扱い、origMembers = {} を返します。その後 ZADD でメンバーが追加されてコミットされると、buildZSetStateElems は次のパスを通ります:

case len(st.origMembers) == 0:
    // Brand new ZSet: full write
    return buildZSetWriteElems(keyBytes, st.members)  // TTL キーに触れない

buildZSetWriteElems はメタ・メンバー・スコアキーを書き込みますが、古い期限切れ TTL キー(!redis|ttl|<userKey>)を削除しません

結果として:

  1. ZADD が正常にコミットされ、wide-column キーが書き込まれる
  2. 次の読み取り時に readValueAthasExpiredTTLAt が期限切れ TTL キーを検出
  3. 新しく書き込んだデータが即座に「期限切れ」として扱われ見えなくなる

対比: 同じファイル内の fromLegacy + empty パスでは TTL を明示的に削除しており、設計意図はあったことが伺えます:

case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},  // ← こちらは正しく削除
    }, nil

修正案: buildZSetStateElemsorigMembers == 0 パスで、TTL state が期限切れかどうかを確認し、期限切れであれば redisTTLKey(keyBytes) の削除 elem を追加する。


2. キーエンコーディングの一貫性

  • !zs|meta|, !zs|mem|, !zs|scr| の各プレフィックスは重複なし
  • 4バイト大端 userKey 長プレフィックスにより、あるユーザーキーが別のユーザーキーのプレフィックスになるケースでの誤マッチを防止
  • EncodeSortableFloat64 によりスコアのバイト順がスコア順に対応 — 問題なし

3. diff ベースコミットのアトミック性

buildZSetDiffElems が生成する全 Put/Del オペレーションは 1 つの OperationGroup に含まれ、Raft 経由でアトミックに適用されます。部分書き込みは発生しません — 問題なし。

4. メタカウントとメンバー数の整合性

buildZSetDiffElems 内でカーディナリティ変化時のみメタキーを更新。スコア変更時はカウント不変でメタ更新をスキップする設計も正しいです — 問題なし。

5. レガシー移行のアトミック性

fromLegacy == true 時は wide-column 書き込み + レガシー blob 削除が同一バッチ内で実行。SSI の readKeys トラッキングに redisZSetKey(key)store.ZSetMetaKey(key) の両方が含まれるため、concurrent 移行の競合も正しく検出されます — 問題なし。


まとめ

観点 結果
キーエンコーディングの一貫性 ✅ 問題なし
diff コミットのアトミック性 ✅ 問題なし
レガシー移行の安全性 ✅ 問題なし
TTL とデータ本体の整合性 要修正

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Consistency] レビュー結果

対象PR: #483 (adapter: decompose ZSet storage into per-member wide-column keys)
※ PR #499 (SSI read-set validation) との関連で分析した内容も含みます。


🔴 [重要] buildZSetStateElems でのTTL削除の非対称性

adapter/redis_compat_helpers.gobuildZSetStateElems にて:

case len(st.members) == 0 && st.fromLegacy:
    // Legacy: TTLキーを明示的に削除 ✅
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},
    }, nil

case len(st.members) == 0:
    // Wide-column: buildZSetDiffElems に委譲 → TTL削除なし ⚠️
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members)

legacyケースは redisTTLKey を明示的に削除するが、wide-columnケースは buildZSetDiffElems を使用しTTLキーを削除しない。TTL削除は buildTTLElems() に委ねられており、ttlStates が適切に更新されている場合のみ機能する。

懸念: ZREM で全メンバーを削除するパスで ttlStates[key].value = nil がセットされない場合、TTLキーが孤立する。孤立したTTLキーが残ると、後で同じキーに作成されたZSetが古いTTLエントリを継承するリスクがある。

確認事項: 全メンバーが削除される全コードパスで ttlStates が更新されているか確認してください。buildZSetStateElems のwide-columnケースに redisTTLKey の削除を追加することも検討してください。


⚠️ [軽微] buildZSetDiffElems による削除は origMembers のキーのみ対象

ZSet削除時(len(st.members) == 0 ケース)に buildZSetDiffElems を使うと、origMembers に含まれるキーのみが削除される。一方、deleteZSetWideColumnElems はフルプレフィックススキャンで孤立キーも削除する(コメント: "including any orphaned keys from partial writes or meta.Len inconsistencies")。

正常なSSI動作下では問題ないが、防御的観点から txnContextパスのZSet削除も deleteZSetWideColumnElems のフルスキャン方式に統一することを検討してください。


✅ ZSet wide-column read-set追跡

trackTypeReadKeys()store.ZSetMetaKey(key) を追加したことは正しい。ZSetへの全書き込みはメタキーを更新するため、メタキーの追跡でZSet全体の競合検知に十分。


deleteZSetWideColumnElems のフルスキャン削除

deleteLogicalKeyElems 経由のZSet削除(DEL コマンド等)がフルプレフィックススキャンで全wide-columnキーを削除する実装は正しく、TTLキーも含めて削除されている。


buildZSetDiffElems のメタLen更新

差分更新時、len(origMembers) != len(newMembers) の場合のみメタを更新する実装は正しい。スコアのみ変更の場合はLen不変であるため効率的かつ正確。


✅ レガシー→wide-column マイグレーション原子性

buildZSetStateElemsfromLegacy=true かつ members 非空のケースで、wide-columnへの全データ書き込みとlegacy blobの削除が同一 OperationGroup(単一Raftエントリ)内で行われるため、原子的に実行される。


まとめ

# 分類 内容
1 🔴 重要 wide-columnケースでのTTL削除非対称性 — 孤立TTLキーのリスク
2 ⚠️ 軽微 buildZSetDiffElems 削除が origMembers のみ対象(孤立キー対策として deleteZSetWideColumnElems の使用を検討)

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

[高] deleteZSetWideColumnElems の無制限スキャン (math.MaxInt)

場所: adapter/redis_compat_helpers.go (deleteZSetWideColumnElems)

scanLimit := math.MaxInt
for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    for _, kvp := range kvs {
        elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
    }
}

ZSet が大量メンバー(例: 100 万件)を持つ場合、全エントリをヒープ上に展開してから削除 Elem を構築する。メンバーキーとスコアインデックスキーで 2 倍のスキャンが走るため、最悪ケースで 200 万件以上の bytes.Clone が発生する。

コメントに kv.DelPrefix が使えない制約が記載されているのは理解できるが、meta.Len から上限 (meta.Len * 2 + 余裕分) を算出してスキャン上限に使うことで、メタ不整合があっても合理的な上限を設けられる。


[中] origMembers の全コピーが読み取り専用ケースでも毎回発生

場所: adapter/redis.go:126-129 (loadZSetState)、adapter/redis_lua_context.go:919-922 (zsetState)

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

ZSet をロードするたびに全メンバーをコピーしてスナップショットを保持する。dirty = false のまま終わる読み取り専用トランザクション(ZRANGE、ZCARD 等)でも O(N) コピーが必ず発生する。ZSet が 10 万メンバーを持つ場合は 10 万要素のマップコピーが常に走る。

改善案: origMembersdirty = true になった時点で遅延コピーする(copy-on-write)。もしくは diff 計算側で members のインプレース変更を追跡するログを持つ設計にする。


[中] zsetUserKeyComponent がホットループ内で二重アロケーション

場所: store/zset_helpers.go (ZSetMemberKey, ZSetScoreKey)、adapter/redis_compat_helpers.go (buildZSetRemoveEntryElems, buildZSetDiffElems)

// buildZSetRemoveEntryElems のループ内
for _, e := range entries {
    memberBytes := []byte(e.Member)
    elems = append(elems,
        &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetMemberKey(key, memberBytes)}, // zsetUserKeyComponent(key) → alloc
        &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetScoreKey(key, e.Score, memberBytes)}, // zsetUserKeyComponent(key) → alloc x2
    )
}

ZSetMemberKeyZSetScoreKey はそれぞれ内部で zsetUserKeyComponent(key) を呼び、毎回 make([]byte, ...) でバッファを確保する。同一 key に対して両関数を呼ぶループでは、zsetUserKeyComponent(key) の結果(!zs|xxx|<len><key> 相当)が毎エントリ 2 回計算・アロケートされる。

改善案: ループの前に comp := store.ZSetUserKeyComponent(key) を一度だけ計算してキャッシュする内部ヘルパーを用意するか、key を引数に取る代わりに comp ([]byte) を受け取る低レベル関数を作成することで、1 エントリあたりのアロケーションを半減できる。


[低] rawKeyTypeAt で新旧両フォーマットを常に 2 回チェック

場所: adapter/redis_compat_helpers.go:589-590 (rawKeyTypeAt)

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},
{typ: redisTypeZSet, key: redisZSetKey(key)}, // legacy blob fallback

型判定のたびに新フォーマット (!zs|meta|...) とレガシーフォーマット (!redis|zset|...) の両方に ExistsAt / GetAt が発行される。新フォーマット移行後もレガシーキーへのアクセスが常に走るため、移行完了後は不要なディスクアクセスとなる。

移行完了前は許容範囲だが、移行完了フラグ or メタが存在した場合はレガシーキーチェックをスキップするショートサーキットを検討してほしい。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

[高] localKeysPattern で ZSet メタプレフィックス全体をスキャン

adapter/redis.golocalKeysPattern:

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {

list / hash / set 等は listPatternScanBounds(prefix, pattern) でスキャン範囲をパターンに絞り込むのに対し、ZSet メタキーは !zs|meta| プレフィックス全体をスキャンして collectUserKeys で後処理フィルタリングする実装になっています。KEYS user:1* のような選択的パターンでも DB 内の全 ZSet エントリを走査する O(total ZSet count) になり、他の型と比べて一貫性を欠きます。listPatternScanBounds 相当の境界計算を ZSet メタキーにも適用することを検討してください。


[中] deleteZSetWideColumnElems の初期スライス容量なし + math.MaxInt スキャン

adapter/redis_compat_helpers.go:

elems := []*kv.Elem[kv.OP]{
    {Op: kv.Del, Key: store.ZSetMetaKey(key)},
}
// ...
for _, kvp := range kvs {
    elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}

cap=1 のスライスに対して member scan・score scan の2ループで 2N 件を追加するため、N が大きいと複数回のリアロケーションが発生します。meta.Len が既知であれば make([]*kv.Elem[kv.OP], 0, 1+meta.Len*2) で事前確保できます。

また scanLimit := math.MaxInt は巨大 ZSet の全メンバーを一度にメモリへロードします。FSM の制約(DelPrefix を他 mutation と混在できない)上、逐次削除は避けられませんが、使用量が多いケースでは注意が必要です。


[小] buildZSetDiffElems の過剰キャパシティ確保

adapter/redis_compat_helpers.go:

elems := make([]*kv.Elem[kv.OP], 0, (len(origMembers)+len(newMembers))*2+1)

1000 メンバー中 1 つのスコアだけ変更するケースでは cap=4001 を確保しますが実際の使用量は 3 です。スコア変更のみや少数変更が多い実ワークロードでは大幅な過剰確保になります。len(origMembers)*2+1 程度が現実的な上限です。


[小] zsetUserKeyComponent のループ内重複アロケーション

store/zset_helpers.goZSetMemberKeyZSetScoreKey はそれぞれ内部で zsetUserKeyComponent(userKey) を呼ぶため、buildZSetWriteElemsbuildZSetDiffElems の member ループ内で同一 userKey に対して毎イテレーション 2 回アロケートします。ループ外で comp := zsetUserKeyComponent(key) を一度計算して再利用すれば、N メンバーあたり 2N-1 件の余分なアロケーションを削減できます。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Concurrency] レビュー結果

PR 483: ZSetストレージ分解 (adapter: decompose ZSet storage into per-member wide-column keys)


1. loadZSetMembersMappersistZSetMembersTxn 間の TOCTOU(潜在リスク)

場所: adapter/redis.go loadZSetMembersMapbuildZSetDiffElems

ZSet の wide-column 移行処理では:

  1. loadZSetMembersMaporigMembers スナップショットを取得
  2. トランザクション処理後、buildZSetDiffElemsorigMembers vs members の差分を計算してコミット

この間に別のトランザクションが同じ ZSet メンバーを変更した場合、差分計算が古いスナップショットに基づいてしまう可能性があります。

trackTypeReadKeysstore.ZSetMetaKey(key) が追加されているため(PR 499 の read-set validation と組み合わせることで)、このウィンドウは SSI の LatestCommitTS チェックで検出される見込みです。ただし、PR 499 で指摘の通り読み取り専用シャードには TOCTOU ウィンドウが残ります。

評価: PR 499 の SSI 検証と組み合わせで基本的には保護されるが、マルチシャード・読み取り専用シャードのエッジケースで競合が漏れる可能性がある。


2. スキャン制限と差分計算の整合性

場所: adapter/redis_compat_helpers.go scanZSetScoreEntries

// bounded limit を使用
func (r *RedisServer) scanZSetScoreEntries(ctx, key, limit, readTS) ([]redisZSetEntry, error)
  • deleteZSetWideColumnElemsmath.MaxInt (完全スキャン) を使用
  • zremrangebyrankTxn など一部操作は bounded limit を使用

部分スキャン結果で origMembers を構築した場合、buildZSetDiffElems による差分が不完全になり、削除すべきメンバーキーが残留する可能性があります。

評価: スキャン上限に ZSetMeta.Len を使用しているため通常は問題ないが、Len とメンバーキー実数の不整合(fromLegacy 移行中など)でスキャン漏れが起きうる境界条件を確認することを推奨。


3. fromLegacy フラグによる移行の一貫性(問題なし)

  • fromLegacy = true の場合は差分書き込みではなく完全書き込み + レガシー blob 削除
  • ネットワーク分断でロールバックが発生しても、次回アクセスで fromLegacy が再検出され完全書き込みで修復される
  • アトミックな FSM 適用により、部分的な移行状態がコミットされることはない

4. ロック順序・ゴルーチンリーク(問題なし)

  • loadZSetMetaAt, scanZSetScoreEntries は store の読み取り操作のみで新規ロック取得なし
  • buildZSetDiffElems は純粋関数でロック不使用
  • 新規 goroutine 生成なし、チャネル操作なし

5. Raft リーダー切り替え時の動作(問題なし)

  • ZSet の差分コミットは既存の Dispatch → FSM apply パスを経由
  • リーダー切り替え時は既存エラーハンドリングでリトライ
  • fromLegacy フラグによる状態管理はコミット済みの Raft ログに基づくため冪等

総評

重大な並行性バグはなし。以下の確認を推奨します:

  1. ZSet スキャン上限: scanZSetAllMembersmemberCount を limit に使用しているが、fromLegacy 移行中など ZSetMeta.Len と実際のメンバーキー数が乖離するケースで完全スキャンされるか検証
  2. PR 499 との連携: 本 PR の SSI 保護は PR 499 の read-set validation に依存しているため、両 PR のマージ順序に注意

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


1. localKeysPattern における全 ZSet メタキーの無条件フルスキャン — adapter/redis.go

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {

ZSet メタキーが長さプレフィックス付きエンコーディングを使うため、ユーザーパターンに基づく範囲絞り込みができず、KEYS patternSCAN の呼び出し毎にストア上の 全 ZSet メタキー を読み込みます。ZSet が大量に存在する環境では、マッチする ZSet 数によらず O(全 ZSet 数) のスキャンが発生するため、他のキー型と比べて著しく非効率です。エンコーディング変更やスキャン後の絞り込みコストについてドキュメントに明記することを推奨します。


2. ZSet 削除時の O(N) Raft エントリ — adapter/redis_compat_helpers.go deleteZSetWideColumnElems

scanLimit := math.MaxInt
for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    for _, kvp := range kvs {
        elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
    }
}

N メンバーの ZSet を削除すると、メンバーキー N 件 + スコアインデックスキー N 件 = 2N 個の個別 kv.Del 要素 が単一の Raft ログエントリに含まれます。メンバー数が多い ZSet では Raft ログエントリが巨大になり、Raft の書き込み増幅が O(N) となります。コメントに「kv.DelPrefix を他のミューテーションと混在できないため個別削除が必要」と記載されていますが、大規模 ZSet に対する削除コストがシステムの SLA に影響しないか検証を推奨します。


3. トランザクション内の全 ZSet ロードで O(N) の origMembers コピー — adapter/redis.go loadZSetStateadapter/redis_lua_context.go zsetState

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

トランザクション内で ZSet に触れるたびに、読み込み専用アクセスの場合でも origMembers マップを O(N) で複製しています。st.dirty が立つ(実際に書き込みが発生する)まで origMembers の初期化を遅延させることで、読み取りのみの ZSet アクセス時のアロケーションと O(N) コピーを省略できます。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Data Loss] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


🔴 [重大] 全削除時に並行書き込みメンバーが orphaned key として残留 ─ データロスト

場所: adapter/redis_compat_helpers.go:buildZSetStateElemsbuildZSetDiffElems

ZSet の全メンバーを削除する際(len(st.members) == 0 かつ !fromLegacy)、以下のコードが実行されます:

case len(st.members) == 0:
    // Delete meta + all member/score keys via prefix deletion.
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members)

buildZSetDiffElemsロード時に取得した origMembers に含まれるキーしか削除しません。ロード後に別トランザクションが追加したメンバーのキーは削除されません。

具体的なデータロストシナリオ(シングルシャード構成):

  1. T1: ZSet をロード (origMembers = {a: 1.0})
  2. T2: メンバー b を追加してコミット (TS=50 → ZSetMetaKey に Len=2、ZSetMemberKey(b)ZSetScoreKey(b) を書き込み)
  3. T1: 全メンバーを削除 → buildZSetDiffElems({a:1.0}, {}) を実行
    • 削除される: ZSetMemberKey(a)ZSetScoreKey(a)ZSetMetaKey
    • 残留する: ZSetMemberKey(b)ZSetScoreKey(b)
  4. T1 コミット成功(T2 との write-write コンフリクトなし)
  5. 次回読み込み: ZSetMetaKey なし → レガシー blob なし → ZSet は「存在しない」と判定
  6. T2 が書き込んだメンバー b はストレージに存在するが、ユーザーからアクセス不能 → データロスト

なぜ SSI で防げるはずなのに防げないか:

PR #483 では ZSetMetaKeytrackReadKey に追加しており、SSI が機能すれば T1 は T2 の ZSetMetaKey 更新を検出してリトライします。しかし PR #499 でシングルシャードトランザクションの readKeys が Raft ログ前に破棄されるためvalidateReadSet 未実装)、SSI チェックが動作しません。

推奨対応:


🟡 [要注意] meta.Len が不正確な場合に buildZSetRemoveEntryElems でメタキーが早期削除される

場所: adapter/redis_compat_helpers.go:buildZSetRemoveEntryElems

newLen := currentLen - int64(len(entries))
if newLen <= 0 {
    elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetMetaKey(key)})
}

bzPopMinWideColumn では limit=1 のスキャンで取得した1エントリのみを削除し、currentLen (= meta.Len) - 1 で新しい件数を計算します。meta.Len が実際のストア件数より少ない場合(既存不整合や上記シナリオで発生し得る)、newLen == 0 となり メタキーが削除されますが、実際にはメンバーが残存 しています。以降のすべての読み込みでそのメンバーが不可視になります。


🟡 [要注意] scanZSetAllMembers のスキャン上限が meta.Len に依存

場所: adapter/redis_compat_helpers.go:scanZSetAllMembers

func (r *RedisServer) scanZSetAllMembers(ctx context.Context, key []byte, memberCount int64, readTS uint64) ([]redisZSetEntry, error) {
    return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)
}

meta.Len よりも多くのメンバーが実際にストアに存在する場合(orphaned key 発生後など)、超過分は読み込まれません。正確性のために math.MaxInt でスキャンして実件数で上限をかけるか、meta.Len の整合性を保証する仕組みが必要です。


まとめ

重大度 問題
🔴 重大 全削除時に並行書き込みメンバーが orphaned key 化 → データロスト(PR #499 の SSI ギャップと組み合わせで確実に発生)
🟡 要注意 meta.Len 不整合時の早期メタキー削除 → 残存メンバーが不可視
🟡 要注意 scanZSetAllMembersmeta.Len 依存 → 超過メンバーの読み取り漏れ

PR #499 (validateReadSet 実装) と本PR はセットでマージされることを強く推奨します。本PR 単独では、シングルシャード構成で全削除 + 並行追加のシナリオにてデータロストが発生し得ます。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Consistency] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


[重大] score-only 更新時に ZSetMetaKey が更新されず、MULTI トランザクションの競合検知が漏れる

場所: adapter/redis_compat_helpers.gobuildZSetDiffElems / adapter/redis.goloadZSetState

read tracking の変更:

// 変更前
t.trackReadKey(redisZSetKey(key))

// 変更後
t.trackReadKey(store.ZSetMetaKey(key))

buildZSetDiffElems のメタ更新条件:

// count が変わらない場合(スコアのみ変更)は ZSetMetaKey を更新しない
} else if len(origMembers) != len(newMembers) {
    // ZSetMetaKey を更新
}

競合シナリオ:

  1. MULTI トランザクション(startTS=10)が ZSet を読む → loadZSetStateZSetMetaKey をトラック
  2. concurrent な ZINCRBY がメンバーのスコアのみを変更(count 不変)→ buildZSetDiffElemsZSetMetaKey の TS 更新なし
  3. MULTI トランザクションの validateReadSetZSetMetaKey をチェック → 変化なし → 競合を検知できない
  4. MULTI トランザクションが stale なスコアデータを前提にコミット → write skew 発生

旧実装では ZSet 全体を 1 blob(redisZSetKey)で管理していたため、どんな変更でも blob の LatestCommitTS が更新され、この問題は発生しなかった。

修正案:

  • 案A: buildZSetDiffElems でスコア変更時も常に ZSetMetaKey を更新する(version counter として機能させる)
  • 案B: loadZSetState でメンバーキー(ZSetMemberKey)も個別にトラックし、スコア更新を検知できるようにする

[確認済み] deleteLogicalKeyElems は wide-column キーを正しく処理

deleteLogicalKeyElems の PR ブランチ実装に deleteZSetWideColumnElems 呼び出しが追加されており、persistZSetMembersTxn での空集合削除パスでも meta/member/score キーが適切にクリーンアップされる。問題なし。


[確認済み] TTL と ZSet データの整合性

legacy から wide-column への移行時、TTL は ttlTxnState 経由で独立管理されるため整合性は維持される。問題なし。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

問題1: deleteZSetWideColumnElemsmath.MaxInt で無制限スキャン ⚠️ 重要

場所: adapter/redis_compat_helpers.go deleteZSetWideColumnElems

scanLimit := math.MaxInt  // ← 件数上限なし

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    ...
    for _, kvp := range kvs {
        elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
    }
}

メンバー数 N の ZSet に対して DELETE KEY を実行すると、全メンバーキー+スコアインデックスキー(計 2N キー)をメモリにロードしてから elems スライスを構築します。旧来の単一 blob 削除が O(1) であったのに対し、O(N) のメモリ消費・I/O に退化しています。

直前の loadZSetMetaAt で取得した meta.Len をスキャン上限として使用すれば、少なくとも正常系の上界を抑えられます(孤立キーのケアが必要な点はトレードオフ)。


問題2: ZSet ロード時に origMembers を常に O(N) コピー ⚠️ 中程度

場所: adapter/redis.go loadZSetStateadapter/redis_lua_context.go zsetState

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

差分コミット目的で origMembers のスナップショットを取っていますが、これはトランザクション内で ZSet を読み取るだけ(ZSCORE, ZRANK 等)のケースでも実行されます(dirty == false でも loadZSetState 呼び出し時点でコピーが走る)。大きな ZSet を読み取るだけのトランザクションでメモリ使用量が約2倍になります。

改善案: origMembers のコピーを遅延させ、最初の書き込み操作(ZADD/ZREM 等)時に実施する。


問題3: rawKeyTypeAt で ZSet のチェックが 2 回に増加 (minor)

場所: adapter/redis_compat_helpers.go rawKeyTypeAt

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},
{typ: redisTypeZSet, key: redisZSetKey(key)}, // legacy blob fallback

TYPE コマンドや型チェックが走るたびに、ZSet の存在確認が最大 2 回の点読みになります。他の型は 1 回です。高頻度な TYPE チェックパスで若干のオーバーヘッドが積み重なります。


良い点

  • zremrangebyrankInner の wide-column パスで e+1 件のみスキャン(旧来の全件ロードからの改善)
  • bzPopMinWideColumn で limit=1 スキャンのみ実行(大幅な I/O 削減)
  • buildZSetDiffElemselems スライス容量を (orig+new)*2+1 で事前確保

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Test Coverage] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


十分にカバーされている箇所

  • buildZSetDiffElems: 5ユニットテスト(score変更/メンバー追加・削除/変更なし/全削除)
  • store/zset_helpers.go のエンコーディング関数: 10以上のユニットテスト(float64ソート順・ラウンドトリップ・メタmarshal等)
  • key抽出関数(ExtractZSetUserKey, ExtractZSetScoreAndMember, IsZSetInternalKey
  • レガシーJSON→wide-column マイグレーション: TestRedisZSetLegacyJSONReadThenRewriteToProto でカバー
  • リトライエラー正規化: TestNormalizeRetryableRedisTxnErrZSetKey

不足しているテスト

1. buildZSetStateElems の4分岐が未テスト (高優先度)

新規追加の buildZSetStateElems は4つの分岐を持つが、いずれも直接テストされていない:

分岐 条件 テスト
レガシーの全削除 len(members)==0 && fromLegacy
wide-columnの全削除 len(members)==0 buildZSetDiffElems経由で間接的のみ
レガシー→wide-columnマイグレーション fromLegacy redis_storage_migration_testで間接的のみ
新規ZSet作成 len(origMembers)==0

提案するテストケース:

func TestBuildZSetStateElems_LegacyEmpty(t *testing.T) { ... }
func TestBuildZSetStateElems_NewZSet(t *testing.T) { ... }
func TestBuildZSetStateElems_LegacyMigration(t *testing.T) { ... }

2. zremrangebyrankInner のテストなし (高優先度)

wide-column path と legacy blob fallback の2パスを持つ新関数だが、どちらもユニットテスト・統合テストが存在しない。

提案するテストケース:

  • wide-column: ZREMRANGEBYRANK key 0 -1(全削除)
  • wide-column: 範囲が空(s > e)の場合 removed=0 を返す
  • legacy blob fallback: 旧フォーマットデータへの ZREMRANGEBYRANK

3. bzPopMinWideColumn / bzPopMinLegacy のテストなし

tryBZPopMinInner がwide-column/legacyの両パスに分岐する新実装だが、テストがない。

提案するテストケース:

  • wide-column: 最低スコアのメンバーがpopされ、meta.Lenが更新される
  • wide-column: ZSetが空の場合 nil を返す
  • legacy: レガシーデータからのBZPOPMIN

4. wide-columnフォーマットへの純粋な書き込み・読み込みの統合テストなし

redis_storage_migration_test.go はレガシーからの移行テストだが、最初からwide-columnに書き込んで読み込むシナリオのテストがない。

提案するテストケース:

// ZADD → ZRANGE → ZREM → ZCARDを、レガシーデータなしの
// 新規wide-columnデータでエンドツーエンドに確認

5. loadZSetMembersMap wide-columnパスの単体テストなし

loadZSetMembersMap はwide-column first → legacy fallbackの順で検索するが、wide-columnが存在するケースの単体テストがない。

6. rawKeyTypeAt でのZSetキー種別検出テストなし

rawKeyTypeAtZSetMetaKey と レガシー redisZSetKey の両方をチェックするように変更されたが、各フォーマットでの TYPE コマンド動作テストがない。

7. redisVisibleUserKey のZSetInternalKeyテストなし

IsZSetInternalKey ブランチが追加されたが、localKeysPattern / KEYS コマンドでwide-columnキーからユーザーキーが正しく見えることのテストがない。

8. 大量メンバーのZSetに対するテストなし

scanZSetAllMembersmeta.Len を上限として使用するが、meta.Len が実際のエントリ数より小さい場合(不整合時)の動作テストが不足。


まとめ

項目 評価
buildZSetDiffElems ユニットテスト ✅ 十分
ZSetキーエンコーディング ✅ 十分
レガシー→wide-column マイグレーション ✅ 基本カバー
buildZSetStateElems 各分岐 ❌ 未テスト
zremrangebyrankInner ❌ テストなし
bzPopMinWideColumn / bzPopMinLegacy ❌ テストなし
wide-column純粋書き込み統合テスト ❌ テストなし
loadZSetMembersMap wide-columnパス ❌ 単体テストなし

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Consistency] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


🔴 問題1: meta.Len の不整合によるランクベース操作の誤動作リスク

場所: adapter/redis_compat_helpers.gozremrangebyrankInner

s, e := normalizeRankRange(start, stop, int(meta.Len))  // meta.Len を信頼してスキャン上限を決定
entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS)  // e+1 件だけスキャン
s, e = normalizeRankRange(start, stop, len(entries))  // 実スキャン件数で再正規化

meta.Len が実際のメンバー数より少なく記録されている場合(例: ノードクラッシュで meta が書き込まれたが一部 member キーが欠落した場合)、e+1 のスキャン上限が不足し、削除すべきエントリを見逃す可能性がある。

同様に scanZSetAllMembersmeta.Len をそのまま上限に使用しており、under-count の場合は全メンバーを返さない。

推奨対応: ランクベース操作では meta.Len をヒント(メモリ確保用)として扱い、スキャンは math.MaxInt 上限で行うか、または meta.Len の正確性を保証する invariant を明示する。


🟡 問題2: ローリングアップグレード中の ZSet コンフリクト検出漏れ

場所: adapter/redis.gotxnContext.loadZSetState (変更箇所)

旧コード: t.trackReadKey(redisZSetKey(key)) — レガシー blob キーを追跡
新コード: t.trackReadKey(store.ZSetMetaKey(key)) — wide-column メタキーを追跡

新フォーマット (wide-column) では ZSetMetaKey を書き込み、旧フォーマット (legacy blob) では redisZSetKey を書き込む。ローリングアップグレード中に旧ノードが redisZSetKey へ書き込んだ場合、新ノードのトランザクションが ZSetMetaKey の read-set を持っていても、そのコンフリクトは SSI チェックで検出されない(異なるストレージキーのため)。

なお trackTypeReadKeys は引き続き両方のキーを追跡するため、TYPE チェックは問題ない。SSI の read-write コンフリクト検出のみが対象。

推奨対応: 移行期間中のリスクをコメントで明記するか、もしくは loadZSetState でも両方のキーを追跡する。


✅ 問題なしの点

  • buildZSetDiffElems のスコア変更時の meta 更新: スコア変更のみ(メンバー数不変)の場合は meta.Len を更新しない設計は正しい ✅
  • 旧 blob + wide-column の両フォーマット削除: deleteLogicalKeyElemsredisZSetKey(レガシー)と deleteZSetWideColumnElems(wide-column)の両方を削除する ✅
  • レガシー移行の原子性: buildZSetStateElemsfromLegacy パスでは、新フォーマット書き込みとレガシー blob 削除を同一トランザクションに含めており、原子的に移行される ✅
  • EncodeSortableFloat64 の符号ビット処理: NaN/Inf を含む IEEE 754 float64 の大小関係がバイト列の辞書順と対応している ✅
  • TTL との整合性: TTL 状態チェックはアダプタレベルで time.Now() を用いており、FSM ではなくアダプタ側での一貫した処理 ✅

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

[中] loadZSetState での origMembers O(N) eager コピー

adapter/redis.goloadZSetState にて、ZSet をロードするたびに全メンバーを origMembers へコピーしている。

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

ZSCOREZCOUNTZCARD のような読み取り専用コマンドでもこのコピーが発生する。メンバー数 N が多い場合、O(N) アロケーション + O(N) コピーが毎回かかる。

改善案: dirty になった時点で初めて origMembers を確定する lazy copy パターンにすれば、読み取り専用パスのオーバーヘッドをゼロにできる。


[軽微] キー生成関数での 2 重アロケーション

store/zset_helpers.go 内の ZSetMetaKeyZSetMemberKeyZSetScoreKeyZSetMemberScanPrefixZSetScoreRangeStartZSetScoreRangeEnd はすべて同じパターンを踏む。

comp := zsetUserKeyComponent(userKey)  // 1 回目のアロケーション
buf := make([]byte, 0, len(prefix)+len(comp))  // 2 回目のアロケーション

zsetUserKeyComponent が中間バッファを生成し、呼び出し元がさらに最終バッファを生成する。ZADD・ZRANGEBYSCORE などのホットパスではメンバーごとにこれが走るため、アロケーション数が 2 倍になる。

改善案: 各キー生成関数内で prefix + len-prefix + userKey + ... を 1 回の make で確保し、zsetUserKeyComponent を廃止するか unsafe を使わず inline 化する。


その他の変更(diff-based commit・buildZSetDiffElems の cap 指定等)は性能上の懸念なし。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Concurrency] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys


Info: diff-based コミットのアトミック性(問題なし)

場所: adapter/redis_compat_helpers.gopersistZSetMembersTxn()

スナップショット取得(origMembers)→ インメモリ変更 → 差分の dispatchElems() という流れは正しいトランザクショナルパターンです。差分計算はトランザクションコンテキスト内で完結しており、別goroutineとの競合ウィンドウはありません。


Info: レガシー→ワイドカラム移行のアトミック性(問題なし)

場所: adapter/redis_compat_helpers.gofromLegacy ブランチ

ワイドカラム形式への書き込みとレガシーblobの削除が単一の dispatchElems() 呼び出しにまとめられており、Raftログへの適用も1エントリで行われます。移行中に中間状態が露出することはなく、ノード障害・リーダー切り替えが発生しても一貫性は保たれます。


Info: プレフィックススキャンのキー衝突(問題なし)

場所: adapter/redis.golocalKeysPattern()

ZSetメタキーに4バイト長プレフィックス(zsetUserKeyComponent)を使うことで、あるuserKeyが別のuserKeyのプレフィックスになる場合のキー衝突を回避しています。スキャン範囲の設計は安全です。


Info: 型読み取りキーの追跡(問題なし)

場所: adapter/redis.gotrackTypeReadKeys()

redisZSetKey(レガシー)と store.ZSetMetaKey(新形式)の両方を read-set に追加しており、移行期間中も両フォーマットに対して衝突検出が正しく機能します。


総評

Race condition、デッドロック、goroutineリーク、ブロッキングチャネルはいずれも検出されませんでした。スナップショット・diff・アトミックdispatchのパターンが一貫して適用されており、並行性の観点では問題なしと判断します。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Test Coverage] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


十分にカバーされている箇所

  • buildZSetDiffElems: 5テスト(スコア変更、メンバー削除、追加、変更なし、全削除)
  • EncodeSortableFloat64 / DecodeSortableFloat64: 3テスト(ラウンドトリップ、負のゼロ、順序)
  • MarshalZSetMeta / UnmarshalZSetMeta: 3テスト(ラウンドトリップ、負のLen、不正バイナリ)
  • PrefixEnd, ExtractZSetUserKey, ExtractZSetScoreAndMember, IsZSetInternalKey: ユニットテストあり

テスト不足の箇所

1. buildZSetStateElems(adapter/redis.go)

5つの分岐があるがテストがゼロ。コミット時の中核ロジック。提案するテストケース:

// 分岐1: len(members)==0 && fromLegacy → legacyキーとTTLキーをDel
// 分岐2: len(members)==0 (wide-column) → meta+全member/scoreキーをDel
// 分岐3: fromLegacy=true → wide-columnへの完全書き込み + legacyキーDel(マイグレーション)
// 分岐4: len(origMembers)==0 → 新規ZSet完全書き込み
// 分岐5: 通常のdiff更新

2. persistZSetMembersTxn(adapter/redis.go)

テストなし。

// fromLegacy=true: wide-column書き込み + legacyキー削除の両方が発行されること
// fromLegacy=false: buildZSetDiffElemsが呼ばれること
// newMembers空: deleteLogicalKeyElemsにフォールバックすること

3. loadZSetEntriesSorted / loadZSetMembersMap(adapter/redis.go)

wide-columnとlegacyの2パスがあるがテストなし。fromLegacyフラグが正しくセットされることの検証も必要。

4. zremrangebyrankInner(adapter/redis.go)

リファクタリングで2パス(wide-column / legacy)に分かれたがテストなし。提案するテストケース:

// wide-columnパス: metaExistsで正しいrank範囲のエントリが削除されること
// legacyパス: legacyフォールバックで正しく動作すること
// 境界値: start/stopが実際のエントリ数を超える場合(e < s → removed=0)

5. bzPopMinWideColumn / bzPopMinLegacy(adapter/redis.go)

テストなし。

// wide-column空ZSet: result=nil が返ること
// wide-column通常: 最小スコアのエントリがpopされ、wide-columnキーが削除されること
// legacyパス: legacy形式で正しく動作すること

6. buildZSetRemoveEntryElems / deleteZSetWideColumnElems(adapter/redis.go)

新規関数だがテストなし。

7. readValueAt のZSetキーTTLパス(adapter/redis.go)

ZSet内部キーに対するTTL有効期限チェックの新コードパスのテストなし。

8. localKeysPattern でZSetMetaPrefixをスキャン(adapter/redis.go)

KEYSコマンドが wide-column ZSetキーを返すかのテストなし。

// wide-column形式でZADDしたキーがKEYSコマンドで返ること

9. trackTypeReadKeys のZSetMetaKey追加(adapter/redis.go)

PR #499のSSI機能と連携する重要な変更だが、ZSetメタキーがreadKeyとして正しく追跡されるかのテストなし。

10. 並行アクセステスト

wide-column ZSet操作(ZADD、ZREM、ZREMRANGEBYRANK)に対して並行テストがない。従来はlocked blobs単一キーだったが、wide-columnは複数キーへの分散書き込みになるため並行安全性の検証が特に重要。


まとめ

ストアヘルパー(key encoding/decoding)とdiff計算はよくテストされていますが、実際のZSet操作(write/read/delete)のend-to-endテストと**buildZSetStateElemsのロジック分岐テスト**が不足しています。特にlegacy→wide-columnマイグレーションパス(fromLegacy=true)はコードが書かれているものの、adapter/redis_storage_migration_test.goへの対応テストケースが見当たりません。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


1. deleteZSetWideColumnElemsmath.MaxInt の無制限スキャンを行う【重要】

ファイル: adapter/redis_compat_helpers.go

scanLimit := math.MaxInt
for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    // → 全メンバーキー + スコアインデックスキーをメモリに全量読み込み
    for _, kvp := range kvs {
        elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
    }
}

数百万メンバーを持つ ZSet を削除する場合、全メンバーキーとスコアインデックスキーを一度にメモリへ展開してから個別の Del 操作に変換する。OOM やタイムアウトの原因になり得る。

案: ZSet の meta に Len が記録されているため、scanLimitmeta.Len を上限として使用する。また、既存の kv.DelPrefix が同一トランザクション内で使えないという制約をコメントに記載しているが、ページング削除や専用の FSM オペレーションも検討余地あり。


2. localKeysPattern での ZSetMetaPrefix スキャンがパターン絞り込み未対応【重要】

ファイル: adapter/redis.go

// ZSet: 常にプレフィックス全体をスキャン(パターンによる絞り込みなし)
zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {
    ...
}

// 他のキータイプ: パターンでスキャン範囲を絞り込んでいる
internalStart, internalEnd := listPatternScanBounds(prefix, pattern)

List や Hash 等の他のキータイプは listPatternScanBounds(prefix, pattern) でスキャン範囲をパターンに絞るが、ZSet のメタキーは常に全件スキャンされる。ZSet が多い環境で KEYS foo:bar:* のような狭いパターンを発行しても全 ZSet メタキーが走査される。

ZSet メタキーのエンコード形式(4バイト長プレフィックス付き)により単純な文字列境界が使えないとのことだが、少なくとも上限を meta.Len ベースで制限するか、別途パターンフィルタ関数を実装することが望ましい。


3. zaddTxn / zincrby / zrem での不要なメンバーマップコピー【中程度】

ファイル: adapter/redis_compat_commands.go

load, err := r.loadZSetMembersMap(...)
members := make(map[string]float64, len(load.members))
for k, v := range load.members {
    members[k] = v   // load.members の完全コピー
}
// ... members を変更 ...
return added, r.persistZSetMembersTxn(ctx, key, readTS, load, members)

persistZSetMembersTxnbuildZSetDiffElemsload.members(元)と newMembers(変更後)のdiffを計算するため、元データの保存は必要。しかし、非トランザクションパスでは loadZSetMembersMap → コピー → 変更 → persist の流れで、O(N) のマップコピーが毎回発生する(N = メンバー数)。

diff のためにオリジナルを保持する設計は合理的だが、元マップを直接変更しつつ変更点だけ記録する「変更追跡」アプローチにすると、大規模 ZSet でのメモリ消費を削減できる。


4. buildZSetDiffElems の容量確保が過大【軽微】

ファイル: adapter/redis_compat_helpers.go

elems := make([]*kv.Elem[kv.OP], 0, (len(origMembers)+len(newMembers))*2+1)

実際に生成される要素数は「変更されたメンバー数 × 2 + メタ更新1件」であるが、両マップのメンバー数の合計×2 を確保している。1件だけ変更した場合でも O(N) のスロットが確保される。実際の diff サイズに基づいた容量か、小さな初期容量に抑えた方がメモリ効率が良い。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Test Coverage] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys


テストが追加されているロジック

変更箇所 テスト有無
buildZSetDiffElems(差分要素生成) TestBuildZSetDiffElems* (5件)
store.EncodeSortableFloat64 / DecodeSortableFloat64 ✅ round-trip / ordering / negative-zero
store.MarshalZSetMeta / UnmarshalZSetMeta ✅ round-trip / エラー系
store.PrefixEnd
store.ExtractZSetUserKey / ExtractZSetScoreAndMember / IsZSetInternalKey
Legacy→wide-column マイグレーション後の読み書き TestRedisZSetLegacyJSONReadThenRewriteToProto 更新済み
TestNormalizeRetryableRedisTxnErrZSetKey

テストが不足している箇所

1. buildZSetStateElems の4分岐ロジックが直接テストされていない(重要)

switch {
case len(st.members) == 0 && st.fromLegacy:  // (1) Legacy削除
case len(st.members) == 0:                    // (2) wide-column削除
case st.fromLegacy:                           // (3) Legacy→wide-column移行
case len(st.origMembers) == 0:               // (4) 新規ZSet
default:                                      // (5) 差分更新
}

buildZSetDiffElems の単体テストはあるが buildZSetStateElems 自体のテストがない。特に分岐(1)(3)はlegacyフラグが絡み、間違えやすい。

提案するテストケース:

func TestBuildZSetStateElems_LegacyEmpty(t *testing.T) { ... }      // fromLegacy=true, members={}
func TestBuildZSetStateElems_LegacyMigration(t *testing.T) { ... }  // fromLegacy=true, members非空
func TestBuildZSetStateElems_NewZSet(t *testing.T) { ... }          // origMembers={}
func TestBuildZSetStateElems_Diff(t *testing.T) { ... }             // 通常の差分更新

2. TTL期限切れZSetへの書き込みパスが未テスト

loadZSetState にTTL期限切れ時のショートカット(origMembers: {}を返す)があるが、その状態で buildZSetStateElems を呼ぶと分岐(4)「新規ZSet」として扱われる。期限切れキーに上書きするシナリオのテストがない。

// 期限切れのZSetにZADDすると、新規ZSetとして全量書き込まれること
func TestZAddAfterTTLExpiry_WritesFullSet(t *testing.T) { ... }

3. redisVisibleUserKey のZSetキー対応が未テスト

if store.IsZSetInternalKey(key) {
    return store.ExtractZSetUserKey(key)
}

redis.go:1302 付近のこの追加コードにテストがない。redisVisibleUserKey にZSetの各内部キー(meta / member / score)を渡したときのユーザーキー復元を確認するテストが望ましい。

4. resolveLoadKeys のZSet分岐が未テスト

if uk == nil && store.IsZSetInternalKey(key) {
    uk = store.ExtractZSetUserKey(key)
}

ZSet内部キーを引数にした場合の (storageKey, userKey, internal) の戻り値を確認するテストがない。

5. 大量メンバーのZSetに対するスキャン動作が未テスト

新しいwide-column形式ではメンバー数だけキーが存在する。scanZSetAllMembers / ZRANGEBYSCORE などの範囲スキャンが多数メンバーで正しく機能するかの境界テストがない。

// 1000メンバーのZSetをZRANGEBYSCOREで全取得できること
func TestZRangeByScore_LargeMemberSet(t *testing.T) { ... }

6. 空文字列メンバー名の扱いが未テスト

ZSetMemberKey(userKey, []byte(""))ZSetScoreKey(userKey, 1.0, []byte("")) が正しいキーを生成すること、また ExtractZSetScoreAndMember が空メンバーを正しく復元することのテストがない。


まとめ

エンコーディングユーティリティ層(store/zset_helpers_test.go)と差分生成(buildZSetDiffElems)のテストは充実している。一方、buildZSetStateElems の分岐テストTTL期限切れZSetへの書き込みについては未テストであり、レグレッションリスクがある。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Consistency] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


問題1: レガシーZSetの読み取りキー追跡の欠落 [重要]

該当箇所: adapter/redis.go:1533 (loadZSetState) と adapter/redis_compat_helpers.go:169-190 (loadZSetMembersMap)

loadZSetState はトランザクションの読み取りキーとして store.ZSetMetaKey(key) のみを登録します:

t.trackReadKey(store.ZSetMetaKey(key))  // メタキーのみ追跡

しかし loadZSetMembersMap の実装は:

  1. まず store.ZSetMetaKey(key) を確認
  2. メタキーが存在しない(レガシー形式)場合は redisZSetKey(key) (レガシーblobキー) から読み取る

レガシー形式のZSetに対して実際に読み取られるのは redisZSetKey(key) ですが、追跡されるキーは store.ZSetMetaKey(key) です。

PR #499のSSIと組み合わせたとき: checkReadConflictsstore.ZSetMetaKey(key) を確認しても該当キーが存在しないため exists=false となり競合として検出されません。その間に redisZSetKey(key) への並行書き込みや別トランザクションによるレガシー→ワイドカラム移行が行われても、startTSとcommitTSの間の変更が見逃されます。

修正案: loadZSetMembersMap がレガシー形式にフォールバックした場合 (fromLegacy == true)、loadZSetState 内で t.trackReadKey(redisZSetKey(key)) も追加する。


問題2: trackTypeReadKeys では両キーが追跡されるが loadZSetState では片方のみ [不整合]

該当箇所: adapter/redis.go:1434-1448 vs adapter/redis.go:1533

trackTypeReadKeys (TYPEコマンド用) では redisZSetKey(key)store.ZSetMetaKey(key) の両方を追跡しています。しかし loadZSetState (ZADD/ZREM等のZSet操作用) は store.ZSetMetaKey(key) のみ追跡しています。ZSet操作の方がデータを実際に読み書きするため、より厳密な追跡が必要です。


問題なしの確認事項

  • buildZSetDiffElemsorigMembers/members 分離とdiff計算: 正しく実装 ✓
  • レガシー移行(wide-column書き込み + レガシーblob削除)が単一Raftリクエストにまとめられアトミック: 正しい ✓
  • buildZSetWriteElems / buildZSetDiffElems のキーエンコーディング一貫性: 正しい ✓
  • zsetTxnState.origMembers が防御的コピーとして作成されている: 正しい ✓
  • loadZSetMembersMap がワイドカラム形式を優先しレガシー形式にフォールバックする順序: 一貫している ✓

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Data Loss] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys

結論: データロストリスクなし(ただし2点の注意事項あり)


確認内容

1. レガシー blob → ワイドカラム移行のアトミック性

persistZSetMembersTxnadapter/redis.go)で buildZSetWriteElems + Del legacyKey を単一 dispatchElems で送出。移行は Raft コミット単位でアトミック。レガシー blob が残ったままワイドカラムが消える/その逆のパタークは発生しない。✅

2. buildZSetDiffElems のスコアインデックス整合性

スコア変更時に旧スコアインデックスキーを削除し新スコアインデックスキーを追加する操作は、同一 dispatch でアトミック。孤児スコアインデックスキーは生まれない。✅

3. deleteLogicalKeyElems でのワイドカラム ZSet 削除

deleteZSetWideColumnElemsmath.MaxInt をスキャン上限として全 member / score-index キーを列挙して削除エレムを生成。meta.Len に依存しないため、仮に meta.Len が実エントリ数と乖離していても全キーが削除される。✅

4. bzPopMinWideColumn の「先取り set」問題

*result のセットは dispatch 成功に行われる(adapter/redis_compat_commands.go)。dispatch 失敗時は result が nil のまま返却。ポップ結果の消失なし。✅

5. buildZSetStateElems - legacy empty パス

fromLegacy && len(members)==0 の場合は redisZSetKeyredisTTLKey のみ削除。このパスが通るのは loadZSetMembersMap がワイドカラム meta を検出できなかった場合(fromLegacy: true)に限定されるため、ワイドカラムキーが存在しないことが保証されている。✅


注意事項1: meta.Len に依存したスキャン上限(理論的リスク)

場所: adapter/redis_compat_helpers.goscanZSetAllMembers

func (r *RedisServer) scanZSetAllMembers(..., memberCount int64, ...) {
    return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)
}

meta.Len が実エントリ数より小さい場合、loadZSetMembersMap は一部のメンバーを読み飛ばす。その後の buildZSetDiffElems は読み飛ばしたメンバーを origMembers に持たないため、次回 diff コミット時に明示的削除が行われず「幽霊エントリ」が残る可能性がある。ただし、全 write パス(buildZSetWriteElems / buildZSetDiffElems)は meta.Len を常にアトミックに更新するため、通常運用で meta.Len の乖離は発生しない。ストアに直接不整合を書き込む障害が起きた場合の保護にはならない点を認識しておく。

注意事項2: deleteZSetWideColumnElems の TOCTOU(実害なし)

場所: adapter/redis_compat_helpers.go

deleteZSetWideColumnElemsreadTS 時点でスキャンを行い delete エレムを生成する。スキャン後~dispatch 前に並行書き込みが ZSet メンバーを追加した場合、そのメンバーは削除リストに含まれない。しかし、メタキーの LatestCommitTS が startTS より大きくなるため write-write 競合として検出・リトライされる。TOCTOU はコンフリクト検出で保護済み。✅


データロスト判定: 問題なし

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Data Loss] レビュー結果

対象PR: #483 (adapter: decompose ZSet storage into per-member wide-column keys)


問題1 【中】トランザクション内でZSet全削除時にTTLキーが残存 → 再作成後のデータ消失リスク

該当箇所: adapter/redis_compat_helpers.gobuildZSetStateElems()

case len(st.members) == 0 && st.fromLegacy:
    // fromLegacy=true のとき: legacyブロブ + TTLキー を削除 ✓
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},   // ← TTL削除あり
    }, nil
case len(st.members) == 0:
    // wide-column のとき: meta + member + score キーを削除するがTTLキーは削除しない ✗
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members)

シナリオ:

  1. ZSet foo にTTL (例: 10分後) を設定済み
  2. MULTI / ZREM foo m1 m2 m3 / EXEC で全メンバーを削除
  3. buildZSetDiffElems は meta・member・score キーを削除するが、!redis|ttl|foo は削除しない
  4. 後から ZADD foo 1.0 newmember で同キーに新ZSetを作成(TTL未設定)
  5. 旧TTLが期限切れになると loadZSetState が「期限切れ」と判断 → 新メンバーが読めなくなりデータ消失

補足: 非トランザクションパス(persistZSetMembersTxndeleteLogicalKeyElems)はTTLを正しく削除しており、一貫性が取れていない。なお旧コードも同じ問題(redisZSetKeyのみ削除でTTL未削除)を持っていた既存バグであり、本PRで fromLegacy=true のケースは修正済み(改善)だが、wide-columnのケースが取り残されている。

推奨対応: buildZSetDiffElems 呼び出し前(または内部)で len(newMembers) == 0 のとき TTL キーの削除エレメントも追加する。


問題2 【低】buildZSetRemoveEntryElemsnewLenmeta.Len 依存

該当箇所: adapter/redis_compat_helpers.gobuildZSetRemoveEntryElems()

func buildZSetRemoveEntryElems(key []byte, entries []redisZSetEntry, currentLen int64) ([]*kv.Elem[kv.OP], error) {
    newLen := currentLen - int64(len(entries))  // meta.Len が実態と乖離していると残余カウントが誤る
    ...
    if newLen <= 0 {
        // meta キーを削除
    } else {
        // meta キーを newLen で更新  ← 実際のメンバー数と不一致の可能性
    }
}

ZREMRANGEBYRANK / BZPOPMIN の wide-column パスでは、meta.Len をそのまま currentLen として渡している(zremrangebyrankInner / bzPopMinWideColumn)。meta.Len が実際のエントリ数より大きい場合(部分的な書き込み失敗等の異常系)、削除後に newLen > 0 となりメタキーが残存、以降のスキャンが meta.Len をリミットとして使用するため過少スキャンが発生しうる。

通常運用では write-write コンフリクト検出により meta.Len の乖離は防がれるため影響は限定的。


確認済み事項(問題なし)

  • deleteLogicalKeyElems でのwide-columnクリーンアップ: deleteZSetWideColumnElems でmeta・全memberキー・全score-indexキーをスキャン削除している。DEL / RENAME / SET(上書き)等でZSetが削除される際のデータ残存は発生しない。
  • migration (legacy → wide-column) のアトミシティ: wide-columnへの書き込みとlegacyブロブの削除が単一の dispatchElems 呼び出しで処理されており、部分適用は発生しない。
  • buildZSetDiffElems のメタキー処理: len(newMembers) == 0 のとき ZSetMetaKey を削除し、count変化があれば更新している。正常系での孤立metaキーは残らない。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant